mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
[Feature] Add RMA support (#4488)
* Adds ReturnOrder and ReturnOrderAttachment models
* Adds new 'role' specific for return orders
* Refactor total_price into a mixin
- Required for PurchaseOrder and SalesOrder
- May not be required for ReturnOrder (remains to be seen)
* Adds API endpoints for ReturnOrder
- Add list endpoint
- Add detail endpoint
- Adds required serializer models
* Adds basic "index" page for Return Order model
* Update API version
* Update navbar text
* Add db migration for new "role"
* Add ContactList and ContactDetail API endpoints
* Adds template and JS code for manipulation of contacts
- Display a table
- Create / edit / delete
* Splits order.js into multiple files
- Javascript files was becoming extremely large
- Hard to debug and find code
- Split into purchase_order / return_order / sales_order
* Fix role name (change 'returns' to 'return_order')
- Similar to existing roles for purchase_order and sales_order
* Adds detail page for ReturnOrder
* URL cleanup
- Use <int:pk> instead of complex regex
* More URL cleanup
* Add "return orders" list to company detail page
* Break JS status codes into new javascript file
- Always difficult to track down where these are rendered
- Enough to warrant their own file now
* Add ability to edit return order from detail page
* Database migrations
- Add new ReturnOrder modeles
- Add new 'contact' field to external orders
* Adds "contact" to ReturnOrder
- Implement check to ensure that the selected "contact" matches the selected "company"
* Adjust filters to limit contact options
* Fix typo
* Expose 'contact' field for PurchaseOrder model
* Render contact information
* Add "contact" for SalesOrder
* Adds setting to enable / disable return order functionality
- Simply hides the navigation elements
- API is not disabled
* Support filtering ReturnOrder by 'status'
- Refactors existing filter into the OrderFilter class
* js linting
* More JS linting
* Adds ReturnOrderReport model
* Add serializer for the ReturnOrderReport model
- A little bit of refactoring along the way
* Admin integration for new report model
* Refactoring for report.api
- Adds generic mixins for filtering queryset (based on updates to label.api)
- Reduces repeated code a *lot*
* Exposes API endpoints for ReturnOrderReport
* Adds default example report file for ReturnOrder
- Requires some more work :)
* Refactor report printing javascript code
- Replace all existing functions with 'printReports'
* Improvements for default StockItem test report template
- Fix bug in template
- Handle potential errors in template tags
- Add more helpers to report tags
- Improve test result rendering
* Reduce logging verbosity from weasyprint
* Refactor javascript for label printing
- Consolidate into a single function
- Similar to refactor of report functions
* Add report print button to return order page
* Record user reference when creating via API
* Refactor order serializers
- Move common code into AbstractOrderSerializer class
* Adds extra line item model for the return order
- Adds serializer and API endpoints as appropriate
* Render extra line table for return order
- Refactor existing functions into a single generic function
- Reduces repeated JS code a lot
* Add ability to create a new extra line item
* Adds button for creating a new lien item
* JS linting
* Update test
* Typo fix
(cherry picked from commit 28ac2be35b
)
* Enable search for return order
* Don't do pricing (yet) for returnorder extra line table
- Fixes an uncaught error
* Error catching for api.js
* Updates for order models:
- Add 'target_date' field to abstract Order model
- Add IN_PROGRESS status code for return order
- Refactor 'overdue' and 'outstanding' API queries
- Refactor OVERDUE_FILTER on order models
- Refactor is_overdue on order models
- More table filters for return order model
* JS cleanup
* Create ReturnOrderLineItem model
- New type of status label
- Add TotalPriceMixin to ReturnOrder model
* Adds an API serializer for the ReturnOrderLineItem model
* Add API endpoints for ReturnOrderLineItem model
- Including some refactoring along the way
* javascript: refactor loadTableFilters function
- Pass enforced query through to the filters
- Call Object.assign() to construct a superset query
- Removes a lot of code duplication
* Refactor hard-coded URLS to use {% url %} lookup
- Forces error if the URL is wrong
- If we ever change the URL, will still work
* Implement creation of new return order line items
* Adds 'part_detail' annotation to ReturnOrderLineItem serializer
- Required for rendering part information
* javascript: refactor method for creating a group of buttons in a table
* javascript: refactor common buttons with helper functions
* Allow edit and delete of return order line items
* Add form option to automatically reload a table on success
- Pass table name to options.refreshTable
* JS linting
* Add common function for createExtraLineItem
* Refactor loading of attachment tables
- Setup drag-and-drop as part of core function
* CI fixes
* Refactoring out some more common API endpoint code
* Update migrations
* Fix permission typo
* Refactor for unit testing code
* Add unit tests for Contact model
* Tests for returnorder list API
* Annotate 'line_items' to ReturnOrder serializer
* Driving the refactor tractor
* More unit tests for the ReturnOrder API endpoints
* Refactor "print orders" button for various order tables
- Move into "setupFilterList" code (generic)
* add generic 'label printing' button to table actions buttons
* Refactor build output table
* Refactoring icon generation for js
* Refactoring for Part API
* Fix database model type for 'received_date'
* Add API endpoint to "issue" a ReturnOrder
* Improvements for stock tracking table
- Add new status codes
- Add rendering for SalesOrder
- Add rendering for ReturnOrder
- Fix status badges
* Adds functionality to receive line items against a return order
* Add endpoints for completing and cancelling orders
* Add option to allow / prevent editing of ReturnOrder after completed
* js linting
* Wrap "add extra line" button in setting check
* Updates to order/admin.py
* Remove inline admin for returnorderline model
* Updates to pass CI
* Serializer fix
* order template fixes
* Unit test fix
* Fixes for ReturnOrder.receive_line_item
* Unit testing for receiving line items against an RMA
* Improve example report for return order
* Extend unit tests for reporting
* Cleanup here and there
* Unit testing for order views
* Clear "sales_order" field when returning against ReturnOrder
* Add 'location' to deltas when returning from customer
* Bug fix for unit test
This commit is contained in:
@ -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
|
||||
@ -198,8 +194,7 @@ class SalesOrderExtraLineResource(InvenTreeResource):
|
||||
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SalesOrderExtraLine
|
||||
model = models.SalesOrderExtraLine
|
||||
|
||||
|
||||
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
@ -281,13 +276,92 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('line', 'shipment', 'item',)
|
||||
|
||||
|
||||
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
|
||||
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
|
||||
admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
|
||||
class ReturnOrderResource(InvenTreeResource):
|
||||
"""Class for managing import / export of ReturnOrder data"""
|
||||
|
||||
admin.site.register(SalesOrder, SalesOrderAdmin)
|
||||
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
|
||||
admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin)
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = models.ReturnOrder
|
||||
skip_unchanged = True
|
||||
clean_model_instances = True
|
||||
exclude = [
|
||||
'metadata',
|
||||
]
|
||||
|
||||
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
|
||||
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)
|
||||
|
||||
class ReturnOrderAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the ReturnOrder model"""
|
||||
|
||||
exclude = [
|
||||
'reference_int',
|
||||
]
|
||||
|
||||
list_display = [
|
||||
'reference',
|
||||
'customer',
|
||||
'status',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'reference',
|
||||
'customer__name',
|
||||
'description',
|
||||
]
|
||||
|
||||
autocomplete_fields = [
|
||||
'customer',
|
||||
]
|
||||
|
||||
|
||||
class ReturnOrderLineItemResource(InvenTreeResource):
|
||||
"""Class for managing import / export of ReturnOrderLineItem data"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = models.ReturnOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class ReturnOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for ReturnOrderLine model"""
|
||||
|
||||
resource_class = ReturnOrderLineItemResource
|
||||
|
||||
list_display = [
|
||||
'order',
|
||||
'item',
|
||||
'reference',
|
||||
]
|
||||
|
||||
|
||||
class ReturnOrderExtraLineClass(InvenTreeResource):
|
||||
"""Class for managing import/export of ReturnOrderExtraLine data"""
|
||||
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options"""
|
||||
model = models.ReturnOrderExtraLine
|
||||
|
||||
|
||||
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
|
||||
"""Admin class for the ReturnOrderExtraLine model"""
|
||||
resource_class = ReturnOrderExtraLineClass
|
||||
|
||||
|
||||
# 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)
|
||||
admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin)
|
||||
admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin)
|
||||
|
File diff suppressed because it is too large
Load Diff
53
InvenTree/order/fixtures/return_order.yaml
Normal file
53
InvenTree/order/fixtures/return_order.yaml
Normal file
@ -0,0 +1,53 @@
|
||||
- model: order.returnorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'RMA-001'
|
||||
reference_int: 1
|
||||
description: 'RMA from a customer'
|
||||
customer: 4
|
||||
status: 10 # Pending
|
||||
|
||||
- model: order.returnorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'RMA-002'
|
||||
reference_int: 2
|
||||
description: 'RMA from a customer'
|
||||
customer: 4
|
||||
status: 20 # In Progress
|
||||
|
||||
- model: order.returnorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'RMA-003'
|
||||
reference_int: 3
|
||||
description: 'RMA from a customer'
|
||||
customer: 4
|
||||
status: 30 # Complete
|
||||
|
||||
- model: order.returnorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'RMA-004'
|
||||
reference_int: 4
|
||||
description: 'RMA from a customer'
|
||||
customer: 5
|
||||
status: 40 # Cancelled
|
||||
|
||||
- model: order.returnorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'RMA-005'
|
||||
reference_int: 5
|
||||
description: 'RMA from a customer'
|
||||
customer: 5
|
||||
status: 20 # In progress
|
||||
|
||||
- model: order.returnorder
|
||||
pk: 6
|
||||
fields:
|
||||
reference: 'RMA-006'
|
||||
reference_int: 6
|
||||
description: 'RMA from a customer'
|
||||
customer: 5
|
||||
status: 10 # Pending
|
64
InvenTree/order/migrations/0081_auto_20230314_0725.py
Normal file
64
InvenTree/order/migrations/0081_auto_20230314_0725.py
Normal file
@ -0,0 +1,64 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-14 07:25
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import order.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('company', '0054_companyattachment'),
|
||||
('users', '0006_alter_ruleset_name'),
|
||||
('order', '0080_auto_20230317_0816'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReturnOrder',
|
||||
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)),
|
||||
('description', models.CharField(help_text='Order description', max_length=250, verbose_name='Description')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
|
||||
('creation_date', models.DateField(blank=True, null=True, verbose_name='Creation Date')),
|
||||
('notes', InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Order notes', max_length=50000, null=True, verbose_name='Notes')),
|
||||
('reference', models.CharField(default=order.validators.generate_next_return_order_reference, help_text='Return Order reference', max_length=64, unique=True, validators=[order.validators.validate_return_order_reference], verbose_name='Reference')),
|
||||
('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status')),
|
||||
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')),
|
||||
('issue_date', models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date')),
|
||||
('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')),
|
||||
('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 from which items are being returned', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.company', verbose_name='Customer')),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='customer',
|
||||
field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='return_orders', to='company.company', verbose_name='Customer'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReturnOrderAttachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
|
||||
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
|
||||
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.returnorder')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
30
InvenTree/order/migrations/0082_auto_20230314_1259.py
Normal file
30
InvenTree/order/migrations/0082_auto_20230314_1259.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-14 12:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0054_companyattachment'),
|
||||
('order', '0081_auto_20230314_0725'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='contact',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='returnorder',
|
||||
name='contact',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorder',
|
||||
name='contact',
|
||||
field=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'),
|
||||
),
|
||||
]
|
35
InvenTree/order/migrations/0083_returnorderextraline.py
Normal file
35
InvenTree/order/migrations/0083_returnorderextraline.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-16 02:52
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import djmoney.models.fields
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0082_auto_20230314_1259'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReturnOrderExtraLine',
|
||||
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='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')),
|
||||
('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')),
|
||||
('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)),
|
||||
('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='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.returnorder', verbose_name='Order')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
33
InvenTree/order/migrations/0084_auto_20230321_1111.py
Normal file
33
InvenTree/order/migrations/0084_auto_20230321_1111.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-21 11:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0083_returnorderextraline'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='returnorder',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='returnorder',
|
||||
name='status',
|
||||
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'In Progress'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorder',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
|
||||
),
|
||||
]
|
49
InvenTree/order/migrations/0085_auto_20230322_1056.py
Normal file
49
InvenTree/order/migrations/0085_auto_20230322_1056.py
Normal file
@ -0,0 +1,49 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-22 10:56
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import djmoney.models.fields
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0095_stocklocation_external'),
|
||||
('order', '0084_auto_20230321_1111'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='returnorder',
|
||||
name='total_price',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='returnorder',
|
||||
name='total_price_currency',
|
||||
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReturnOrderLineItem',
|
||||
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='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')),
|
||||
('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')),
|
||||
('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')),
|
||||
('outcome', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (50, 'Refund'), (40, 'Replace'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')),
|
||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
|
||||
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
|
||||
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
|
||||
('item', models.ForeignKey(help_text='Select item to return from customer', on_delete=django.db.models.deletion.CASCADE, related_name='return_order_lines', to='stock.stockitem', verbose_name='Item')),
|
||||
('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.returnorder', verbose_name='Order')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('order', 'item')},
|
||||
},
|
||||
),
|
||||
]
|
23
InvenTree/order/migrations/0086_auto_20230323_1108.py
Normal file
23
InvenTree/order/migrations/0086_auto_20230323_1108.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.18 on 2023-03-23 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0085_auto_20230322_1056'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='returnorderextraline',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='returnorderlineitem',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
@ -26,25 +26,110 @@ import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import order.validators
|
||||
import stock.models
|
||||
import users.models as UserModels
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import currency_code_default
|
||||
from company.models import Company, SupplierPart
|
||||
from company.models import Company, Contact, SupplierPart
|
||||
from InvenTree.exceptions import log_error
|
||||
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,
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
|
||||
ReturnOrderStatus, SalesOrderStatus,
|
||||
StockHistoryCode, StockStatus)
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
from plugin.models import MetadataMixin
|
||||
from stock import models as stock_models
|
||||
from users import models as UserModels
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class TotalPriceMixin(models.Model):
|
||||
"""Mixin which provides 'total_price' field for an order"""
|
||||
|
||||
class Meta:
|
||||
"""Meta for MetadataMixin."""
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Update the total_price field when saved"""
|
||||
|
||||
# Recalculate total_price for this order
|
||||
self.update_total_price(commit=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
total_price = InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
allow_negative=False,
|
||||
verbose_name=_('Total Price'),
|
||||
help_text=_('Total price for this order')
|
||||
)
|
||||
|
||||
def update_total_price(self, commit=True):
|
||||
"""Recalculate and save the total_price for this order"""
|
||||
|
||||
self.total_price = self.calculate_total_price()
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
def calculate_total_price(self, target_currency=None):
|
||||
"""Calculates the total price of all order lines, and converts to the specified target currency.
|
||||
|
||||
If not specified, the default system currency is used.
|
||||
|
||||
If currency conversion fails (e.g. there are no valid conversion rates),
|
||||
then we simply return zero, rather than attempting some other calculation.
|
||||
"""
|
||||
# Set default - see B008
|
||||
if target_currency is None:
|
||||
target_currency = currency_code_default()
|
||||
|
||||
total = Money(0, target_currency)
|
||||
|
||||
# order items
|
||||
for line in self.lines.all():
|
||||
|
||||
if not line.price:
|
||||
continue
|
||||
|
||||
try:
|
||||
total += line.quantity * convert_money(line.price, target_currency)
|
||||
except MissingRate:
|
||||
# Record the error, try to press on
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
log_error('order.calculate_total_price')
|
||||
logger.error(f"Missing exchange rate for '{target_currency}'")
|
||||
|
||||
# Return None to indicate the calculated price is invalid
|
||||
return None
|
||||
|
||||
# extra items
|
||||
for line in self.extra_lines.all():
|
||||
|
||||
if not line.price:
|
||||
continue
|
||||
|
||||
try:
|
||||
total += line.quantity * convert_money(line.price, target_currency)
|
||||
except MissingRate:
|
||||
# Record the error, try to press on
|
||||
|
||||
log_error('order.calculate_total_price')
|
||||
logger.error(f"Missing exchange rate for '{target_currency}'")
|
||||
|
||||
# Return None to indicate the calculated price is invalid
|
||||
return None
|
||||
|
||||
# set decimal-places
|
||||
total.decimal_places = 4
|
||||
|
||||
return total
|
||||
|
||||
|
||||
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
"""Abstract model for an order.
|
||||
|
||||
@ -78,15 +163,49 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
if not self.creation_date:
|
||||
self.creation_date = datetime.now().date()
|
||||
|
||||
# Recalculate total_price for this order
|
||||
self.update_total_price(commit=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Custom clean method for the generic order class"""
|
||||
|
||||
super().clean()
|
||||
|
||||
# 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")
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def overdue_filter(cls):
|
||||
"""A generic implementation of an 'overdue' filter for the Model class
|
||||
|
||||
It requires any subclasses to implement the get_status_class() class method
|
||||
"""
|
||||
|
||||
today = datetime.now().date()
|
||||
return Q(status__in=cls.get_status_class().OPEN) & ~Q(target_date=None) & Q(target_date__lt=today)
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Method to determine if this order is overdue.
|
||||
|
||||
Makes use of the overdue_filter() method to avoid code duplication
|
||||
"""
|
||||
|
||||
return self.__class__.objects.filter(pk=self.pk).filter(self.__class__.overdue_filter()).exists()
|
||||
|
||||
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
|
||||
|
||||
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
|
||||
|
||||
target_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Target Date'),
|
||||
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
|
||||
|
||||
created_by = models.ForeignKey(User,
|
||||
@ -105,84 +224,25 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
notes = InvenTreeNotesField(help_text=_('Order notes'))
|
||||
|
||||
total_price = InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
allow_negative=False,
|
||||
verbose_name=_('Total Price'),
|
||||
help_text=_('Total price for this order')
|
||||
contact = models.ForeignKey(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Contact'),
|
||||
help_text=_('Point of contact for this order'),
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
def update_total_price(self, commit=True):
|
||||
"""Recalculate and save the total_price for this order"""
|
||||
notes = InvenTreeNotesField(help_text=_('Order notes'))
|
||||
|
||||
self.total_price = self.calculate_total_price()
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the enumeration class which represents the 'status' field for this model"""
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
def calculate_total_price(self, target_currency=None):
|
||||
"""Calculates the total price of all order lines, and converts to the specified target currency.
|
||||
|
||||
If not specified, the default system currency is used.
|
||||
|
||||
If currency conversion fails (e.g. there are no valid conversion rates),
|
||||
then we simply return zero, rather than attempting some other calculation.
|
||||
"""
|
||||
# Set default - see B008
|
||||
if target_currency is None:
|
||||
target_currency = currency_code_default()
|
||||
|
||||
total = Money(0, target_currency)
|
||||
|
||||
# gather name reference
|
||||
price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
|
||||
|
||||
# order items
|
||||
for line in self.lines.all():
|
||||
|
||||
price_ref = getattr(line, price_ref_tag)
|
||||
|
||||
if not price_ref:
|
||||
continue
|
||||
|
||||
try:
|
||||
total += line.quantity * convert_money(price_ref, target_currency)
|
||||
except MissingRate:
|
||||
# Record the error, try to press on
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
log_error('order.calculate_total_price')
|
||||
logger.error(f"Missing exchange rate for '{target_currency}'")
|
||||
|
||||
# Return None to indicate the calculated price is invalid
|
||||
return None
|
||||
|
||||
# extra items
|
||||
for line in self.extra_lines.all():
|
||||
|
||||
if not line.price:
|
||||
continue
|
||||
|
||||
try:
|
||||
total += line.quantity * convert_money(line.price, target_currency)
|
||||
except MissingRate:
|
||||
# Record the error, try to press on
|
||||
|
||||
log_error('order.calculate_total_price')
|
||||
logger.error(f"Missing exchange rate for '{target_currency}'")
|
||||
|
||||
# Return None to indicate the calculated price is invalid
|
||||
return None
|
||||
|
||||
# set decimal-places
|
||||
total.decimal_places = 4
|
||||
|
||||
return total
|
||||
raise NotImplementedError(f"get_status_class() not implemented for {__class__}")
|
||||
|
||||
|
||||
class PurchaseOrder(Order):
|
||||
class PurchaseOrder(TotalPriceMixin, Order):
|
||||
"""A PurchaseOrder represents goods shipped inwards from an external supplier.
|
||||
|
||||
Attributes:
|
||||
@ -192,14 +252,23 @@ class PurchaseOrder(Order):
|
||||
target_date: Expected delivery target date for PurchaseOrder completion (optional)
|
||||
"""
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Get the 'web' URL for this order"""
|
||||
return reverse('po-detail', kwargs={'pk': self.pk})
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the PurchaseOrder model"""
|
||||
return reverse('api-po-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the PurchasOrderStatus class"""
|
||||
return PurchaseOrderStatus
|
||||
|
||||
@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(),
|
||||
@ -207,8 +276,6 @@ class PurchaseOrder(Order):
|
||||
|
||||
return defaults
|
||||
|
||||
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||
|
||||
@ -283,6 +350,11 @@ class PurchaseOrder(Order):
|
||||
help_text=_('Company from which the items are being ordered')
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
"""Accessor helper for Order base class"""
|
||||
return self.supplier
|
||||
|
||||
supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
|
||||
|
||||
received_by = models.ForeignKey(
|
||||
@ -299,22 +371,12 @@ class PurchaseOrder(Order):
|
||||
help_text=_('Date order was issued')
|
||||
)
|
||||
|
||||
target_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Target Delivery Date'),
|
||||
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
help_text=_('Date order was completed')
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL of the detail view for this order"""
|
||||
return reverse('po-detail', kwargs={'pk': self.id})
|
||||
|
||||
@transaction.atomic
|
||||
def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None):
|
||||
"""Add a new line item to this purchase order.
|
||||
@ -417,17 +479,6 @@ class PurchaseOrder(Order):
|
||||
"""Return True if the PurchaseOrder is 'pending'"""
|
||||
return self.status == PurchaseOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Returns True if this PurchaseOrder is "overdue".
|
||||
|
||||
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
||||
"""
|
||||
query = PurchaseOrder.objects.filter(pk=self.pk)
|
||||
query = query.filter(PurchaseOrder.OVERDUE_FILTER)
|
||||
|
||||
return query.exists()
|
||||
|
||||
def can_cancel(self):
|
||||
"""A PurchaseOrder can only be cancelled under the following circumstances.
|
||||
|
||||
@ -534,7 +585,7 @@ class PurchaseOrder(Order):
|
||||
|
||||
for sn in serials:
|
||||
|
||||
stock = stock_models.StockItem(
|
||||
item = stock.models.StockItem(
|
||||
part=line.part.part,
|
||||
supplier_part=line.part,
|
||||
location=location,
|
||||
@ -547,14 +598,14 @@ class PurchaseOrder(Order):
|
||||
barcode_hash=barcode_hash
|
||||
)
|
||||
|
||||
stock.save(add_note=False)
|
||||
item.save(add_note=False)
|
||||
|
||||
tracking_info = {
|
||||
'status': status,
|
||||
'purchaseorder': self.pk,
|
||||
}
|
||||
|
||||
stock.add_tracking_entry(
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
user,
|
||||
notes=notes,
|
||||
@ -595,20 +646,23 @@ def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **
|
||||
notify_responsible(instance, sender, exclude=instance.created_by)
|
||||
|
||||
|
||||
class SalesOrder(Order):
|
||||
"""A SalesOrder represents a list of goods shipped outwards to a customer.
|
||||
class SalesOrder(TotalPriceMixin, Order):
|
||||
"""A SalesOrder represents a list of goods shipped outwards to a customer."""
|
||||
|
||||
Attributes:
|
||||
customer: Reference to the company receiving the goods in the order
|
||||
customer_reference: Optional field for customer order reference code
|
||||
target_date: Target date for SalesOrder completion (optional)
|
||||
"""
|
||||
def get_absolute_url(self):
|
||||
"""Get the 'web' URL for this order"""
|
||||
return reverse('so-detail', kwargs={'pk': self.pk})
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SalesOrder model"""
|
||||
return reverse('api-so-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the SalesOrderStatus class"""
|
||||
return SalesOrderStatus
|
||||
|
||||
@classmethod
|
||||
def api_defaults(cls, request):
|
||||
"""Return default values for this model when issuing an API OPTIONS request"""
|
||||
@ -618,8 +672,6 @@ class SalesOrder(Order):
|
||||
|
||||
return defaults
|
||||
|
||||
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
||||
|
||||
@ -663,10 +715,6 @@ class SalesOrder(Order):
|
||||
|
||||
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL for the detail view of this order"""
|
||||
return reverse('so-detail', kwargs={'pk': self.id})
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
@ -684,13 +732,21 @@ 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'))
|
||||
@property
|
||||
def company(self):
|
||||
"""Accessor helper for Order base"""
|
||||
return self.customer
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
default=SalesOrderStatus.PENDING,
|
||||
choices=SalesOrderStatus.items(),
|
||||
verbose_name=_('Status'), help_text=_('Purchase order status')
|
||||
)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
@ -699,12 +755,6 @@ class SalesOrder(Order):
|
||||
|
||||
customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
|
||||
|
||||
target_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Target completion date'),
|
||||
help_text=_('Target date for order completion. Order will be overdue after this date.')
|
||||
)
|
||||
|
||||
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
|
||||
|
||||
shipped_by = models.ForeignKey(
|
||||
@ -715,17 +765,6 @@ class SalesOrder(Order):
|
||||
verbose_name=_('shipped by')
|
||||
)
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Returns true if this SalesOrder is "overdue".
|
||||
|
||||
Makes use of the OVERDUE_FILTER to avoid code duplication.
|
||||
"""
|
||||
query = SalesOrder.objects.filter(pk=self.pk)
|
||||
query = query.filter(SalesOrder.OVERDUE_FILTER)
|
||||
|
||||
return query.exists()
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Return True if this order is 'pending'"""
|
||||
@ -1121,9 +1160,9 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
stock items location will be reported as the location for the
|
||||
entire line.
|
||||
"""
|
||||
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
|
||||
if item.location:
|
||||
return item.location
|
||||
if self.destination:
|
||||
return self.destination
|
||||
if self.part and self.part.part and self.part.part.default_location:
|
||||
@ -1420,7 +1459,11 @@ class SalesOrderExtraLine(OrderExtraLine):
|
||||
"""Return the API URL associated with the SalesOrderExtraLine model"""
|
||||
return reverse('api-so-extra-line-list')
|
||||
|
||||
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Sales Order'))
|
||||
order = models.ForeignKey(
|
||||
SalesOrder, on_delete=models.CASCADE,
|
||||
related_name='extra_lines',
|
||||
verbose_name=_('Order'), help_text=_('Sales Order')
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderAllocation(models.Model):
|
||||
@ -1455,7 +1498,7 @@ class SalesOrderAllocation(models.Model):
|
||||
try:
|
||||
if not self.item:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
except stock_models.StockItem.DoesNotExist:
|
||||
except stock.models.StockItem.DoesNotExist:
|
||||
raise ValidationError({'item': _('Stock item has not been assigned')})
|
||||
|
||||
try:
|
||||
@ -1547,3 +1590,299 @@ class SalesOrderAllocation(models.Model):
|
||||
# (It may have changed if the stock was split)
|
||||
self.item = item
|
||||
self.save()
|
||||
|
||||
|
||||
class ReturnOrder(TotalPriceMixin, 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)
|
||||
"""
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Get the 'web' URL for this order"""
|
||||
return reverse('return-order-detail', kwargs={'pk': self.pk})
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ReturnOrder model"""
|
||||
return reverse('api-return-order-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the ReturnOrderStatus class"""
|
||||
return ReturnOrderStatus
|
||||
|
||||
@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"),
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
"""Accessor helper for Order base class"""
|
||||
return self.customer
|
||||
|
||||
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')
|
||||
)
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
"""Return True if this order is pending"""
|
||||
return self.status == ReturnOrderStatus.PENDING
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
"""Return True if this order is outstanding"""
|
||||
return self.status in ReturnOrderStatus.OPEN
|
||||
|
||||
@property
|
||||
def is_received(self):
|
||||
"""Return True if this order is fully received"""
|
||||
return not self.lines.filter(received_date=None).exists()
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_order(self):
|
||||
"""Cancel this ReturnOrder (if not already cancelled)"""
|
||||
if self.status != ReturnOrderStatus.CANCELLED:
|
||||
self.status = ReturnOrderStatus.CANCELLED
|
||||
self.save()
|
||||
|
||||
trigger_event('returnorder.cancelled', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def complete_order(self):
|
||||
"""Complete this ReturnOrder (if not already completed)"""
|
||||
|
||||
if self.status == ReturnOrderStatus.IN_PROGRESS:
|
||||
self.status = ReturnOrderStatus.COMPLETE
|
||||
self.complete_date = datetime.now().date()
|
||||
self.save()
|
||||
|
||||
trigger_event('returnorder.completed', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def place_order(self):
|
||||
"""Issue this ReturnOrder (if currently pending)"""
|
||||
|
||||
if self.status == ReturnOrderStatus.PENDING:
|
||||
self.status = ReturnOrderStatus.IN_PROGRESS
|
||||
self.issue_date = datetime.now().date()
|
||||
self.save()
|
||||
|
||||
trigger_event('returnorder.placed', id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(self, line, location, user, note=''):
|
||||
"""Receive a line item against this ReturnOrder:
|
||||
|
||||
- Transfers the StockItem to the specified location
|
||||
- Marks the StockItem as "quarantined"
|
||||
- Adds a tracking entry to the StockItem
|
||||
- Removes the 'customer' reference from the StockItem
|
||||
"""
|
||||
|
||||
# Prevent an item from being "received" multiple times
|
||||
if line.received_date is not None:
|
||||
logger.warning("receive_line_item called with item already returned")
|
||||
return
|
||||
|
||||
stock_item = line.item
|
||||
|
||||
deltas = {
|
||||
'status': StockStatus.QUARANTINED,
|
||||
'returnorder': self.pk,
|
||||
'location': location.pk,
|
||||
}
|
||||
|
||||
if stock_item.customer:
|
||||
deltas['customer'] = stock_item.customer.pk
|
||||
|
||||
# Update the StockItem
|
||||
stock_item.status = StockStatus.QUARANTINED
|
||||
stock_item.location = location
|
||||
stock_item.customer = None
|
||||
stock_item.sales_order = None
|
||||
stock_item.save(add_note=False)
|
||||
|
||||
# Add a tracking entry to the StockItem
|
||||
stock_item.add_tracking_entry(
|
||||
StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
|
||||
user,
|
||||
notes=note,
|
||||
deltas=deltas,
|
||||
location=location,
|
||||
returnorder=self,
|
||||
)
|
||||
|
||||
# Update the LineItem
|
||||
line.received_date = datetime.now().date()
|
||||
line.save()
|
||||
|
||||
trigger_event('returnorder.received', id=self.pk)
|
||||
|
||||
# Notify responsible users
|
||||
notify_responsible(
|
||||
self,
|
||||
ReturnOrder,
|
||||
exclude=user,
|
||||
content=InvenTreeNotificationBodies.ReturnOrderItemsReceived,
|
||||
)
|
||||
|
||||
|
||||
class ReturnOrderLineItem(OrderLineItem):
|
||||
"""Model for a single LineItem in a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this model"""
|
||||
|
||||
unique_together = [
|
||||
('order', 'item'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with this model"""
|
||||
return reverse('api-return-order-line-list')
|
||||
|
||||
def clean(self):
|
||||
"""Perform extra validation steps for the ReturnOrderLineItem model"""
|
||||
|
||||
super().clean()
|
||||
|
||||
if self.item and not self.item.serialized:
|
||||
raise ValidationError({
|
||||
'item': _("Only serialized items can be assigned to a Return Order"),
|
||||
})
|
||||
|
||||
order = models.ForeignKey(
|
||||
ReturnOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lines',
|
||||
verbose_name=_('Order'),
|
||||
help_text=_('Return Order'),
|
||||
)
|
||||
|
||||
item = models.ForeignKey(
|
||||
stock.models.StockItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='return_order_lines',
|
||||
verbose_name=_('Item'),
|
||||
help_text=_('Select item to return from customer')
|
||||
)
|
||||
|
||||
received_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Received Date'),
|
||||
help_text=_('The date this this return item was received'),
|
||||
)
|
||||
|
||||
@property
|
||||
def received(self):
|
||||
"""Return True if this item has been received"""
|
||||
return self.received_date is not None
|
||||
|
||||
outcome = models.PositiveIntegerField(
|
||||
default=ReturnOrderLineStatus.PENDING,
|
||||
choices=ReturnOrderLineStatus.items(),
|
||||
verbose_name=_('Outcome'), help_text=_('Outcome for this line item')
|
||||
)
|
||||
|
||||
price = InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Price'),
|
||||
help_text=_('Cost associated with return or repair for this line item'),
|
||||
)
|
||||
|
||||
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
|
||||
|
||||
|
||||
class ReturnOrderExtraLine(OrderExtraLine):
|
||||
"""Model for a single ExtraLine in a ReturnOrder"""
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the ReturnOrderExtraLine model"""
|
||||
return reverse('api-return-order-extra-line-list')
|
||||
|
||||
order = models.ForeignKey(
|
||||
ReturnOrder, on_delete=models.CASCADE,
|
||||
related_name='extra_lines',
|
||||
verbose_name=_('Order'), help_text=_('Return Order')
|
||||
)
|
||||
|
||||
|
||||
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-order-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',
|
||||
)
|
||||
|
@ -17,21 +17,22 @@ import order.models
|
||||
import part.filters
|
||||
import stock.models
|
||||
import stock.serializers
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from company.serializers import (CompanyBriefSerializer, ContactSerializer,
|
||||
SupplierPartSerializer)
|
||||
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||
StockStatus)
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockStatus)
|
||||
from part.serializers import PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
|
||||
class AbstractOrderSerializer(serializers.Serializer):
|
||||
"""Abstract field definitions for OrderSerializers."""
|
||||
class TotalPriceMixin(serializers.Serializer):
|
||||
"""Serializer mixin which provides total price fields"""
|
||||
|
||||
total_price = InvenTreeMoneySerializer(
|
||||
allow_null=True,
|
||||
@ -41,6 +42,69 @@ class AbstractOrderSerializer(serializers.Serializer):
|
||||
total_price_currency = InvenTreeCurrencySerializer(read_only=True)
|
||||
|
||||
|
||||
class AbstractOrderSerializer(serializers.Serializer):
|
||||
"""Abstract serializer class which provides fields common to all order types"""
|
||||
|
||||
# Number of line items in this order
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Human-readable status text (read-only)
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
# status field cannot be set directly
|
||||
status = serializers.IntegerField(read_only=True)
|
||||
|
||||
# Reference string is *required*
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
# Detail for point-of-contact field
|
||||
contact_detail = ContactSerializer(source='contact', many=False, read_only=True)
|
||||
|
||||
# Detail for responsible field
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
|
||||
|
||||
# Boolean field indicating if this order is overdue (Note: must be annotated)
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the reference field"""
|
||||
|
||||
self.Meta.model.validate_reference_field(reference)
|
||||
return reference
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add extra information to the queryset"""
|
||||
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def order_fields(extra_fields):
|
||||
"""Construct a set of fields for this serializer"""
|
||||
|
||||
return [
|
||||
'pk',
|
||||
'creation_date',
|
||||
'target_date',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'reference',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
'contact',
|
||||
'contact_detail',
|
||||
'status',
|
||||
'status_text',
|
||||
'notes',
|
||||
'overdue',
|
||||
] + extra_fields
|
||||
|
||||
|
||||
class AbstractExtraLineSerializer(serializers.Serializer):
|
||||
"""Abstract Serializer for a ExtraLine object."""
|
||||
|
||||
@ -78,7 +142,7 @@ class AbstractExtraLineMeta:
|
||||
]
|
||||
|
||||
|
||||
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for a PurchaseOrder object."""
|
||||
|
||||
class Meta:
|
||||
@ -86,31 +150,17 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'overdue',
|
||||
'reference',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'supplier_reference',
|
||||
'status',
|
||||
'status_text',
|
||||
'target_date',
|
||||
'notes',
|
||||
'total_price',
|
||||
'total_price_currency',
|
||||
]
|
||||
])
|
||||
|
||||
read_only_fields = [
|
||||
'status'
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
@ -132,14 +182,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
- Number of lines in the PurchaseOrder
|
||||
- Overdue status of the PurchaseOrder
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.PurchaseOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -149,24 +198,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the reference field"""
|
||||
|
||||
# Ensure that the reference matches the required pattern
|
||||
order.models.PurchaseOrder.validate_reference_field(reference)
|
||||
|
||||
return reference
|
||||
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
|
||||
|
||||
|
||||
class PurchaseOrderCancelSerializer(serializers.Serializer):
|
||||
"""Serializer for cancelling a PurchaseOrder."""
|
||||
@ -307,7 +338,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
|
||||
order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
@ -531,7 +562,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||
"""Serializer for receiving items against a purchase order."""
|
||||
"""Serializer for receiving items against a PurchaseOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@ -644,34 +675,22 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
])
|
||||
|
||||
|
||||
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializers for the SalesOrder object."""
|
||||
class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrder model class"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'creation_date',
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'customer',
|
||||
'customer_detail',
|
||||
'customer_reference',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'notes',
|
||||
'overdue',
|
||||
'reference',
|
||||
'responsible',
|
||||
'status',
|
||||
'status_text',
|
||||
'shipment_date',
|
||||
'target_date',
|
||||
'total_price',
|
||||
'total_price_currency',
|
||||
]
|
||||
])
|
||||
|
||||
read_only_fields = [
|
||||
'status',
|
||||
@ -695,14 +714,13 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
- Number of line items in the SalesOrder
|
||||
- Overdue status of the SalesOrder
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
order.models.SalesOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
@ -712,22 +730,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
|
||||
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
|
||||
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
reference = serializers.CharField(required=True)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the reference field"""
|
||||
|
||||
# Ensure that the reference matches the required pattern
|
||||
order.models.SalesOrder.validate_reference_field(reference)
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrderAllocation model.
|
||||
@ -1379,13 +1381,13 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for a SalesOrderExtraLine object."""
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
class Meta(AbstractExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrderExtraLine
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
|
||||
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializers for the SalesOrderAttachment model."""
|
||||
@ -1398,3 +1400,253 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'order',
|
||||
])
|
||||
|
||||
|
||||
class ReturnOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for the ReturnOrder model class"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = order.models.ReturnOrder
|
||||
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'customer',
|
||||
'customer_detail',
|
||||
'customer_reference',
|
||||
])
|
||||
|
||||
read_only_fields = [
|
||||
'creation_date',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialization routine for the serializer"""
|
||||
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if customer_detail is not True:
|
||||
self.fields.pop('customer_detail')
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Custom annotation for the serializer queryset"""
|
||||
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
order.models.ReturnOrder.overdue_filter(),
|
||||
then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField())
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
|
||||
|
||||
|
||||
class ReturnOrderIssueSerializer(serializers.Serializer):
|
||||
"""Serializer for issuing a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
fields = []
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'issue' the order"""
|
||||
order = self.context['order']
|
||||
order.place_order()
|
||||
|
||||
|
||||
class ReturnOrderCancelSerializer(serializers.Serializer):
|
||||
"""Serializer for cancelling a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
fields = []
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'cancel' the order"""
|
||||
order = self.context['order']
|
||||
order.cancel_order()
|
||||
|
||||
|
||||
class ReturnOrderCompleteSerializer(serializers.Serializer):
|
||||
"""Serializer for completing a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
fields = []
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to 'complete' the order"""
|
||||
order = self.context['order']
|
||||
order.complete_order()
|
||||
|
||||
|
||||
class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
"""Serializer for receiving a single line item against a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
fields = [
|
||||
'item',
|
||||
]
|
||||
|
||||
item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.ReturnOrderLineItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Return order line item'),
|
||||
)
|
||||
|
||||
def validate_line_item(self, item):
|
||||
"""Validation for a single line item"""
|
||||
|
||||
if item.order != self.context['order']:
|
||||
raise ValidationError(_("Line item does not match return order"))
|
||||
|
||||
if item.received:
|
||||
raise ValidationError(_("Line item has already been received"))
|
||||
|
||||
return item
|
||||
|
||||
|
||||
class ReturnOrderReceiveSerializer(serializers.Serializer):
|
||||
"""Serializer for receiving items against a ReturnOrder"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
fields = [
|
||||
'items',
|
||||
'location',
|
||||
]
|
||||
|
||||
items = ReturnOrderLineItemReceiveSerializer(many=True)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockLocation.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Location'),
|
||||
help_text=_('Select destination location for received items'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Perform data validation for this serializer"""
|
||||
|
||||
order = self.context['order']
|
||||
if order.status != ReturnOrderStatus.IN_PROGRESS:
|
||||
raise ValidationError(_("Items can only be received against orders which are in progress"))
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValidationError(_("Line items must be provided"))
|
||||
|
||||
return data
|
||||
|
||||
@transaction.atomic
|
||||
def save(self):
|
||||
"""Saving this serializer marks the returned items as received"""
|
||||
|
||||
order = self.context['order']
|
||||
request = self.context['request']
|
||||
|
||||
data = self.validated_data
|
||||
items = data['items']
|
||||
location = data['location']
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
line_item = item['item']
|
||||
order.receive_line_item(
|
||||
line_item,
|
||||
location,
|
||||
request.user
|
||||
)
|
||||
|
||||
|
||||
class ReturnOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a ReturnOrderLineItem object"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = order.models.ReturnOrderLineItem
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
'order_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'received_date',
|
||||
'outcome',
|
||||
'part_detail',
|
||||
'price',
|
||||
'price_currency',
|
||||
'link',
|
||||
'reference',
|
||||
'notes',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialization routine for the serializer"""
|
||||
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
item_detail = kwargs.pop('item_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not order_detail:
|
||||
self.fields.pop('order_detail')
|
||||
|
||||
if not item_detail:
|
||||
self.fields.pop('item_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
|
||||
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
|
||||
price = InvenTreeMoneySerializer(allow_null=True)
|
||||
price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency'))
|
||||
|
||||
|
||||
class ReturnOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for a ReturnOrderExtraLine object"""
|
||||
|
||||
class Meta(AbstractExtraLineMeta):
|
||||
"""Metaclass options"""
|
||||
model = order.models.ReturnOrderExtraLine
|
||||
|
||||
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
|
||||
class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the ReturnOrderAttachment model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = order.models.ReturnOrderAttachment
|
||||
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'order',
|
||||
])
|
||||
|
@ -7,16 +7,16 @@
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Purchase Order" %}
|
||||
{% endblock %}
|
||||
{% endblock page_title %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class='breadcrumb-item'><a href='{% url "po-index" %}'>{% trans "Purchase Orders" %}</a></li>
|
||||
<li class='breadcrumb-item'><a href='{% url "purchase-order-index" %}'>{% trans "Purchase Orders" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "po-detail" order.id %}'>{{ order }}</a></li>
|
||||
{% endblock %}
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Purchase Order" %}: {{ order.reference }}
|
||||
{% endblock %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
{% if user.is_staff and roles.purchase_order.change %}
|
||||
@ -67,8 +67,7 @@
|
||||
{% trans "Receive Items" %}
|
||||
</button>
|
||||
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
|
||||
<span class='fas fa-check-circle'></span>
|
||||
{% trans "Complete Order" %}
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@ -82,7 +81,7 @@ src="{{ order.supplier.image.url }}"
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
/>
|
||||
{% endblock %}
|
||||
{% endblock thumbnail %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
@ -111,7 +110,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class='table table-condensed table-striped'>
|
||||
@ -169,7 +168,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
<td>{% render_date order.target_date %}</td>
|
||||
<td>
|
||||
{% render_date order.target_date %}
|
||||
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.COMPLETE %}
|
||||
@ -179,6 +181,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.contact %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Contact" %}</td>
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
@ -201,12 +210,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% endblock details_right %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
$("#place-order").click(function() {
|
||||
|
||||
@ -222,7 +230,11 @@ $("#place-order").click(function() {
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-order-report').click(function() {
|
||||
printPurchaseOrderReports([{{ order.pk }}]);
|
||||
printReports({
|
||||
items: [{{ order.pk }}],
|
||||
key: 'order',
|
||||
url: '{% url "api-po-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -293,4 +305,4 @@ $("#export-order").click(function() {
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% endblock js_ready %}
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'order/po_sidebar.html' %}
|
||||
{% endblock %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block page_content %}
|
||||
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
|
||||
@ -27,9 +27,10 @@
|
||||
<button type='button' class='btn btn-success' id='new-po-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive Line Items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@ -126,7 +127,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
@ -146,30 +147,18 @@
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-po-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
order: {{ order.id }},
|
||||
onPanelLoad('order-attachments', function() {
|
||||
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
$('#attachment-table').bootstrapTable('refresh');
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadStockTable($("#stock-table"), {
|
||||
@ -204,7 +193,7 @@ $('#new-po-line').click(function() {
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
|
||||
$('#receive-selected-items').click(function() {
|
||||
var items = getTableData('#po-line-table');
|
||||
let items = getTableData('#po-line-table');
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
@ -219,59 +208,56 @@ $('#new-po-line').click(function() {
|
||||
|
||||
{% endif %}
|
||||
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.pk }},
|
||||
{% if order.supplier %}
|
||||
supplier: {{ order.supplier.pk }},
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.change %}
|
||||
allow_edit: true,
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||
allow_receive: true,
|
||||
{% else %}
|
||||
allow_receive: false,
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
$("#new-po-extra-line").click(function() {
|
||||
|
||||
var fields = extraLineFields({
|
||||
onPanelLoad('order-items', function() {
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.pk }},
|
||||
});
|
||||
|
||||
{% if order.supplier.currency %}
|
||||
fields.price_currency.value = '{{ order.supplier.currency }}';
|
||||
{% endif %}
|
||||
|
||||
constructForm('{% url "api-po-extra-line-list" %}', {
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Order Line" %}',
|
||||
onSuccess: function() {
|
||||
$("#po-extra-lines-table").bootstrapTable("refresh");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
loadPurchaseOrderExtraLineTable(
|
||||
'#po-extra-lines-table',
|
||||
{
|
||||
order: {{ order.pk }},
|
||||
status: {{ order.status }},
|
||||
{% if order.is_pending %}
|
||||
pending: true,
|
||||
{% if order.supplier %}
|
||||
supplier: {{ order.supplier.pk }},
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.change %}
|
||||
allow_edit: true,
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||
allow_receive: true,
|
||||
{% else %}
|
||||
allow_receive: false,
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
$("#new-po-extra-line").click(function() {
|
||||
|
||||
createExtraLineItem({
|
||||
order: {{ order.pk }},
|
||||
table: '#po-extra-lines-table',
|
||||
url: '{% url "api-po-extra-line-list" %}',
|
||||
{% if order.supplier.currency %}
|
||||
currency: '{{ order.supplier.currency }}',
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
loadExtraLineTable({
|
||||
table: '#po-extra-lines-table',
|
||||
order: {{ order.pk }},
|
||||
url: '{% url "api-po-extra-line-list" %}',
|
||||
name: 'purchaseorderextraline',
|
||||
filtertarget: '#filter-list-purchase-order-extra-lines',
|
||||
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
|
||||
{% if order.is_pending or allow_edit %}
|
||||
allow_edit: {% js_bool roles.purchase_order.change %},
|
||||
allow_delete: {% js_bool roles.purchase_order.delete %},
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
allow_delete: false,
|
||||
{% endif %}
|
||||
pricing: true,
|
||||
});
|
||||
});
|
||||
|
||||
loadOrderTotal(
|
||||
'#poTotalPrice',
|
||||
|
@ -26,11 +26,6 @@
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% if report_enabled %}
|
||||
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% include "filter_list.html" with id="purchaseorder" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -53,20 +48,6 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if report_enabled %}
|
||||
$("#order-print").click(function() {
|
||||
var rows = getTableData('#purchase-order-table');
|
||||
|
||||
var orders = [];
|
||||
|
||||
rows.forEach(function(row) {
|
||||
orders.push(row.pk);
|
||||
});
|
||||
|
||||
printPurchaseOrderReports(orders);
|
||||
})
|
||||
{% endif %}
|
||||
|
||||
$("#po-create").click(function() {
|
||||
createPurchaseOrder();
|
||||
});
|
||||
|
235
InvenTree/order/templates/order/return_order_base.html
Normal file
235
InvenTree/order/templates/order/return_order_base.html
Normal file
@ -0,0 +1,235 @@
|
||||
{% extends "page_base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Return Order" %}
|
||||
{% endblock page_title %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class='breadcrumb-item'><a href='{% url "return-order-index" %}'>{% trans "Return Orders" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "return-order-detail" order.id %}'>{{ order }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block thumbnail %}
|
||||
<img class='part-thumb'
|
||||
{% if order.customer and order.customer.image %}
|
||||
src="{{ order.customer.image.url }}"
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
/>
|
||||
{% endblock thumbnail%}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Return Order" %} {{ order.reference }}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
{% if user.is_staff and roles.return_order.change %}
|
||||
{% url 'admin:order_returnorder_change' order.pk as url %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
<!-- Printing actions -->
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
{% if report_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print return order report" %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
|
||||
<!--
|
||||
<li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
|
||||
-->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if roles.return_order.change %}
|
||||
<!-- Order actions -->
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='order-options' title='{% trans "Order actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
|
||||
{% if order.is_open %}
|
||||
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if order.status == ReturnOrderStatus.PENDING %}
|
||||
<button type='button' class='btn btn-primary' id='submit-order' title='{% trans "Submit Order" %}'>
|
||||
<span class='fas fa-paper-plane'></span> {% trans "Submit Order" %}
|
||||
</button>
|
||||
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
|
||||
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock actions %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<col width='25'>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Order Reference" %}</td>
|
||||
<td>{{ order.reference }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-info-circle'></span></td>
|
||||
<td>{% trans "Order Description" %}</td>
|
||||
<td>{{ order.description }}{% include "clip.html" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Order Status" %}</td>
|
||||
<td>
|
||||
{% return_order_status_label order.status %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
<col width='25'>
|
||||
{% if order.customer %}
|
||||
<tr>
|
||||
<td><span class='fas fa-building'></span></td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.customer_reference %}
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Customer Reference" %}</td>
|
||||
<td>{{ order.customer_reference }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.link %}
|
||||
<tr>
|
||||
<td><span class='fas fa-link'></span></td>
|
||||
<td>External Link</td>
|
||||
<td><a href="{{ order.link }}">{{ order.link }}</a>{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Created" %}</td>
|
||||
<td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
|
||||
</tr>
|
||||
{% if order.issue_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Issued" %}</td>
|
||||
<td>{% render_date order.issue_date %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.target_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
<td>
|
||||
{% render_date order.target_date %}
|
||||
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.contact %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Contact" %}</td>
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
<td>{% trans "Responsible" %}</td>
|
||||
<td>{{ order.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></td>
|
||||
<td>{% trans "Total Cost" %}</td>
|
||||
<td id='roTotalPrice'>
|
||||
{% with order.total_price as tp %}
|
||||
{% if tp == None %}
|
||||
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
||||
{% else %}
|
||||
{% render_currency tp currency=order.customer.currency %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock details_right %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if roles.return_order.change %}
|
||||
|
||||
{% if order.status == ReturnOrderStatus.PENDING %}
|
||||
$('#submit-order').click(function() {
|
||||
issueReturnOrder({{ order.pk }}, {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
|
||||
$('#complete-order').click(function() {
|
||||
completeReturnOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
})
|
||||
{% endif %}
|
||||
|
||||
$('#edit-order').click(function() {
|
||||
editReturnOrder({{ order.pk }}, {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
{% if order.is_open %}
|
||||
$('#cancel-order').click(function() {
|
||||
cancelReturnOrder(
|
||||
{{ order.pk }},
|
||||
{
|
||||
reload: true
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-order-report').click(function() {
|
||||
printReports({
|
||||
items: [{{ order.pk }}],
|
||||
key: 'order',
|
||||
url: '{% url "api-return-order-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
<!-- TODO: Export order callback -->
|
||||
|
||||
{% endblock js_ready %}
|
209
InvenTree/order/templates/order/return_order_detail.html
Normal file
209
InvenTree/order/templates/order/return_order_detail.html
Normal file
@ -0,0 +1,209 @@
|
||||
{% extends "order/return_order_base.html" %}
|
||||
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "order/return_order_sidebar.html" %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block page_content %}
|
||||
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-details'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Line Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.return_order.add %}
|
||||
{% if order.is_open or allow_extra_editing %}
|
||||
<button type='button' class='btn btn-success' id='new-return-order-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
|
||||
<button type='button' class='btn btn-primary' id='receive-line-items' title='{% trans "Receive Line Items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="returnorderlines" %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='return-order-lines-table' data-toolbar='#order-toolbar-buttons'>
|
||||
</table>
|
||||
</div>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Extra Lines" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.return_order.add %}
|
||||
{% if order.is_open or allow_extra_editing %}
|
||||
<button type='button' class='btn btn-success' id='new-return-order-extra-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='order-extra-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="return-order-extra-lines" %}
|
||||
</div>
|
||||
</div>
|
||||
<table class='table table-striped table-condensed' id='return-order-extra-lines-table' data-toolbar='#order-extra-toolbar-buttons'>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-attachments'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "attachment_button.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "attachment_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-order-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Order Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<textarea id='order-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Callback function when the 'details' panel is loaded
|
||||
onPanelLoad('order-details', function() {
|
||||
|
||||
{% if roles.return_order.add %}
|
||||
|
||||
$('#receive-line-items').click(function() {
|
||||
let items = getTableData('#return-order-lines-table');
|
||||
|
||||
receiveReturnOrderItems(
|
||||
{{ order.pk }},
|
||||
items,
|
||||
{
|
||||
onSuccess: function() {
|
||||
reloadBootstrapTable('#return-order-lines-table');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$('#new-return-order-line').click(function() {
|
||||
createReturnOrderLineItem({
|
||||
order: {{ order.pk }},
|
||||
customer: {{ order.customer.pk }},
|
||||
onSuccess: function() {
|
||||
reloadBootstrapTable('#return-order-lines-table');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#new-return-order-extra-line').click(function() {
|
||||
|
||||
createExtraLineItem({
|
||||
order: {{ order.pk }},
|
||||
table: '#return-order-extra-lines-table',
|
||||
url: '{% url "api-return-order-extra-line-list" %}',
|
||||
{% if order.customer.currency %}
|
||||
currency: '{{ order.customer.currency }}',
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
|
||||
|
||||
loadReturnOrderLineItemTable({
|
||||
table: '#return-order-lines-table',
|
||||
order: {{ order.pk }},
|
||||
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
|
||||
allow_receive: true,
|
||||
{% endif %}
|
||||
{% if order.is_open or allow_extra_editing %}
|
||||
allow_edit: {% js_bool roles.return_order.change %},
|
||||
allow_delete: {% js_bool roles.return_order.delete %},
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
loadExtraLineTable({
|
||||
order: {{ order.pk }},
|
||||
url: '{% url "api-return-order-extra-line-list" %}',
|
||||
table: "#return-order-extra-lines-table",
|
||||
name: 'returnorderextralines',
|
||||
filtertarget: '#filter-list-return-order-extra-lines',
|
||||
{% if order.is_open or allow_extra_editing %}
|
||||
allow_edit: {% js_bool roles.return_order.change %},
|
||||
allow_delete: {% js_bool roles.return_order.delete %},
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback function when the 'notes' panel is loaded
|
||||
onPanelLoad('order-notes', function() {
|
||||
setupNotesField(
|
||||
'order-notes',
|
||||
'{% url "api-return-order-detail" order.pk %}',
|
||||
{
|
||||
{% if roles.purchase_order.change %}
|
||||
editable: true,
|
||||
{% else %}
|
||||
editable: false,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Callback function when the 'attachments' panel is loaded
|
||||
onPanelLoad('order-attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-return-order-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
enableSidebar('returnorder');
|
||||
|
||||
{% endblock js_ready %}
|
10
InvenTree/order/templates/order/return_order_sidebar.html
Normal file
10
InvenTree/order/templates/order/return_order_sidebar.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% trans "Order Details" as text %}
|
||||
{% include "sidebar_item.html" with label='order-details' text=text icon="fa-info-circle" %}
|
||||
{% trans "Attachments" as text %}
|
||||
{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %}
|
||||
{% trans "Notes" as text %}
|
||||
{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %}
|
55
InvenTree/order/templates/order/return_orders.html
Normal file
55
InvenTree/order/templates/order/return_orders.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends "page_base.html" %}
|
||||
|
||||
{% load inventree_extras %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Return Orders" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb_list %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Return Orders" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block actions %}
|
||||
{% if roles.return_order.add %}
|
||||
<button class='btn btn-success' type='button' id='return-order-create' title='{% trans "Create new return order" %}'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "New Return Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock actions %}
|
||||
|
||||
{% block page_info %}
|
||||
|
||||
<div class='panel-content'>
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% include "filter_list.html" with id="returnorder" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
|
||||
</table>
|
||||
|
||||
<div id='return-order-calendar'></div>
|
||||
</div>
|
||||
|
||||
{% endblock page_info %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
loadReturnOrderTable('#return-order-table', {});
|
||||
|
||||
$('#return-order-create').click(function() {
|
||||
createReturnOrder();
|
||||
});
|
||||
|
||||
{% endblock js_ready %}
|
@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class='breadcrumb-item'><a href='{% url "so-index" %}'>{% trans "Sales Orders" %}</a></li>
|
||||
<li class='breadcrumb-item'><a href='{% url "sales-order-index" %}'>{% trans "Sales Orders" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "so-detail" order.id %}'>{{ order }}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
@ -162,7 +162,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
<td>{% render_date order.target_date %}</td>
|
||||
<td>
|
||||
{% render_date order.target_date %}
|
||||
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.shipment_date %}
|
||||
@ -177,6 +180,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.contact %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Contact" %}</td>
|
||||
<td>{{ order.contact.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if order.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
@ -187,7 +197,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></td>
|
||||
<td>{% trans "Total cost" %}</td>
|
||||
<td>{% trans "Total Cost" %}</td>
|
||||
<td id="soTotalPrice">
|
||||
{% with order.total_price as tp %}
|
||||
{% if tp == None %}
|
||||
@ -204,12 +214,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if roles.sales_order.change %}
|
||||
$("#edit-order").click(function() {
|
||||
|
||||
editSalesOrder({{ order.pk }}, {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#complete-order-shipments").click(function() {
|
||||
|
||||
@ -242,7 +253,11 @@ $("#complete-order").click(function() {
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-order-report').click(function() {
|
||||
printSalesOrderReports([{{ order.pk }}]);
|
||||
printReports({
|
||||
items: [{{ order.pk }}],
|
||||
key: 'order',
|
||||
url: '{% url "api-so-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
||||
<h4>{% trans "Sales Order Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.sales_order.change %}
|
||||
{% if roles.sales_order.add %}
|
||||
{% if order.is_pending or allow_extra_editing %}
|
||||
<button type='button' class='btn btn-success' id='new-so-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
@ -209,30 +209,19 @@
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-so-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
order: {{ order.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
onPanelLoad('order-attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
}
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadBuildTable($("#builds-table"), {
|
||||
@ -242,60 +231,67 @@
|
||||
},
|
||||
});
|
||||
|
||||
$("#new-so-line").click(function() {
|
||||
createSalesOrderLineItem({
|
||||
order: {{ order.pk }},
|
||||
onSuccess: function() {
|
||||
$("#so-lines-table").bootstrapTable("refresh");
|
||||
onPanelLoad('order-items', function() {
|
||||
|
||||
$("#new-so-line").click(function() {
|
||||
createSalesOrderLineItem({
|
||||
order: {{ order.pk }},
|
||||
onSuccess: function() {
|
||||
$("#so-lines-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new SalesOrderLine item
|
||||
var fields = soLineItemFields({
|
||||
order: {{ order.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
loadSalesOrderLineItemTable(
|
||||
'#so-lines-table',
|
||||
{
|
||||
order: {{ order.pk }},
|
||||
reference: '{{ order.reference }}',
|
||||
status: {{ order.status }},
|
||||
{% if roles.sales_order.change %}
|
||||
allow_edit: true,
|
||||
{% endif %}
|
||||
{% if order.is_pending %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
|
||||
$("#new-so-extra-line").click(function() {
|
||||
|
||||
createExtraLineItem({
|
||||
order: {{ order.pk }},
|
||||
table: '#so-extra-lines-table',
|
||||
url: '{% url "api-so-extra-line-list" %}',
|
||||
{% if order.customer.currency %}
|
||||
currency: '{{ order.customer.currency }}',
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
// Create a new SalesOrderLine item
|
||||
var fields = soLineItemFields({
|
||||
loadExtraLineTable({
|
||||
order: {{ order.pk }},
|
||||
table: '#so-extra-lines-table',
|
||||
url: '{% url "api-so-extra-line-list" %}',
|
||||
name: 'salesorderextraline',
|
||||
filtertarget: '#filter-list-sales-order-extra-lines',
|
||||
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
|
||||
{% if order.is_pending or allow_edit %}
|
||||
allow_edit: {% js_bool roles.sales_order.change %},
|
||||
allow_delete: {% js_bool roles.sales_order.delete %},
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
allow_delete: false,
|
||||
{% endif %}
|
||||
pricing: true,
|
||||
});
|
||||
});
|
||||
|
||||
loadSalesOrderLineItemTable(
|
||||
'#so-lines-table',
|
||||
{
|
||||
order: {{ order.pk }},
|
||||
reference: '{{ order.reference }}',
|
||||
status: {{ order.status }},
|
||||
{% if roles.sales_order.change %}
|
||||
allow_edit: true,
|
||||
{% endif %}
|
||||
{% if order.is_pending %}
|
||||
pending: true,
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
|
||||
$("#new-so-extra-line").click(function() {
|
||||
|
||||
var fields = extraLineFields({
|
||||
order: {{ order.pk }},
|
||||
});
|
||||
|
||||
constructForm('{% url "api-so-extra-line-list" %}', {
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Extra Line" %}',
|
||||
onSuccess: function() {
|
||||
$("#so-extra-lines-table").bootstrapTable("refresh");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
loadSalesOrderExtraLineTable(
|
||||
'#so-extra-lines-table',
|
||||
{
|
||||
order: {{ order.pk }},
|
||||
status: {{ order.status }},
|
||||
{% if roles.sales_order.change %}allow_edit: true,{% endif %}
|
||||
{% if order.is_pending %}pending: true,{% endif %}
|
||||
}
|
||||
);
|
||||
|
||||
loadOrderTotal(
|
||||
'#soTotalPrice',
|
||||
|
@ -29,11 +29,6 @@
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
{% if report_enabled %}
|
||||
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
|
||||
<span class='fas fa-print'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% include "filter_list.html" with id="salesorder" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -54,20 +49,6 @@ loadSalesOrderTable("#sales-order-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
});
|
||||
|
||||
{% if report_enabled %}
|
||||
$("#order-print").click(function() {
|
||||
var rows = getTableData('#sales-order-table');
|
||||
|
||||
var orders = [];
|
||||
|
||||
rows.forEach(function(row) {
|
||||
orders.push(row.pk);
|
||||
});
|
||||
|
||||
printSalesOrderReports(orders);
|
||||
})
|
||||
{% endif %}
|
||||
|
||||
$("#so-create").click(function() {
|
||||
createSalesOrder();
|
||||
});
|
||||
|
@ -17,7 +17,9 @@ import order.models as models
|
||||
from common.settings import currency_codes
|
||||
from company.models import Company
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
|
||||
ReturnOrderStatus, SalesOrderStatus,
|
||||
StockStatus)
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
@ -1802,3 +1804,286 @@ class SalesOrderAllocateTest(OrderTest):
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count())
|
||||
|
||||
|
||||
class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
"""Unit tests for ReturnOrder API endpoints"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'company',
|
||||
'return_order',
|
||||
'part',
|
||||
'location',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
]
|
||||
|
||||
def test_options(self):
|
||||
"""Test the OPTIONS endpoint"""
|
||||
|
||||
self.assignRole('return_order.add')
|
||||
data = self.options(reverse('api-return-order-list'), expected_code=200).data
|
||||
|
||||
self.assertEqual(data['name'], 'Return Order List')
|
||||
|
||||
# Some checks on the 'reference' field
|
||||
post = data['actions']['POST']
|
||||
reference = post['reference']
|
||||
|
||||
self.assertEqual(reference['default'], 'RMA-0007')
|
||||
self.assertEqual(reference['label'], 'Reference')
|
||||
self.assertEqual(reference['help_text'], 'Return Order reference')
|
||||
self.assertEqual(reference['required'], True)
|
||||
self.assertEqual(reference['type'], 'string')
|
||||
|
||||
def test_list(self):
|
||||
"""Tests for the list endpoint"""
|
||||
|
||||
url = reverse('api-return-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'], 'RMA-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'], 'RMA-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': 20,
|
||||
},
|
||||
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 ReturnOrder via the API"""
|
||||
|
||||
url = reverse('api-return-order-list')
|
||||
|
||||
# Do not have required permissions yet
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 1,
|
||||
'description': 'a return order',
|
||||
},
|
||||
expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('return_order.add')
|
||||
|
||||
data = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'customer_reference': 'cr',
|
||||
'description': 'a return order',
|
||||
},
|
||||
expected_code=201
|
||||
).data
|
||||
|
||||
# Reference automatically generated
|
||||
self.assertEqual(data['reference'], 'RMA-0007')
|
||||
self.assertEqual(data['customer_reference'], 'cr')
|
||||
|
||||
def test_update(self):
|
||||
"""Test that we can update a ReturnOrder via the API"""
|
||||
|
||||
url = reverse('api-return-order-detail', kwargs={'pk': 1})
|
||||
|
||||
# Test detail endpoint
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['reference'], 'RMA-001')
|
||||
|
||||
# Attempt to update, incorrect permissions
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'customer_reference': 'My customer reference',
|
||||
},
|
||||
expected_code=403
|
||||
)
|
||||
|
||||
self.assignRole('return_order.change')
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'customer_reference': 'customer ref',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
rma = models.ReturnOrder.objects.get(pk=1)
|
||||
self.assertEqual(rma.customer_reference, 'customer ref')
|
||||
|
||||
def test_ro_issue(self):
|
||||
"""Test the 'issue' order for a ReturnOrder"""
|
||||
|
||||
order = models.ReturnOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.status, ReturnOrderStatus.PENDING)
|
||||
self.assertIsNone(order.issue_date)
|
||||
|
||||
url = reverse('api-return-order-issue', kwargs={'pk': 1})
|
||||
|
||||
# POST without required permissions
|
||||
self.post(url, expected_code=403)
|
||||
|
||||
self.assignRole('return_order.add')
|
||||
|
||||
self.post(url, expected_code=201)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, ReturnOrderStatus.IN_PROGRESS)
|
||||
self.assertIsNotNone(order.issue_date)
|
||||
|
||||
def test_receive(self):
|
||||
"""Test that we can receive items against a ReturnOrder"""
|
||||
|
||||
customer = Company.objects.get(pk=4)
|
||||
|
||||
# Create an order
|
||||
rma = models.ReturnOrder.objects.create(
|
||||
customer=customer,
|
||||
description='A return order',
|
||||
)
|
||||
|
||||
self.assertEqual(rma.reference, 'RMA-0007')
|
||||
|
||||
# Create some line items
|
||||
part = Part.objects.get(pk=25)
|
||||
for idx in range(3):
|
||||
stock_item = StockItem.objects.create(
|
||||
part=part, customer=customer,
|
||||
quantity=1, serial=idx
|
||||
)
|
||||
|
||||
line_item = models.ReturnOrderLineItem.objects.create(
|
||||
order=rma,
|
||||
item=stock_item,
|
||||
)
|
||||
|
||||
self.assertEqual(line_item.outcome, ReturnOrderLineStatus.PENDING)
|
||||
self.assertIsNone(line_item.received_date)
|
||||
self.assertFalse(line_item.received)
|
||||
|
||||
self.assertEqual(rma.lines.count(), 3)
|
||||
|
||||
def receive(items, location=None, expected_code=400):
|
||||
"""Helper function to receive items against this ReturnOrder"""
|
||||
url = reverse('api-return-order-receive', kwargs={'pk': rma.pk})
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'items': items,
|
||||
'location': location,
|
||||
},
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
return response.data
|
||||
|
||||
# Receive without required permissions
|
||||
receive([], expected_code=403)
|
||||
|
||||
self.assignRole('return_order.add')
|
||||
|
||||
# Receive, without any location
|
||||
data = receive([], expected_code=400)
|
||||
self.assertIn('This field may not be null', str(data['location']))
|
||||
|
||||
# Receive, with incorrect order code
|
||||
data = receive([], 1, expected_code=400)
|
||||
self.assertIn('Items can only be received against orders which are in progress', str(data))
|
||||
|
||||
# Issue the order (via the API)
|
||||
self.assertIsNone(rma.issue_date)
|
||||
self.post(
|
||||
reverse("api-return-order-issue", kwargs={"pk": rma.pk}),
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
rma.refresh_from_db()
|
||||
self.assertIsNotNone(rma.issue_date)
|
||||
self.assertEqual(rma.status, ReturnOrderStatus.IN_PROGRESS)
|
||||
|
||||
# Receive, without any items
|
||||
data = receive([], 1, expected_code=400)
|
||||
self.assertIn('Line items must be provided', str(data))
|
||||
|
||||
# Get a reference to one of the stock items
|
||||
stock_item = rma.lines.first().item
|
||||
|
||||
n_tracking = stock_item.tracking_info.count()
|
||||
|
||||
# Receive items successfully
|
||||
data = receive(
|
||||
[{'item': line.pk} for line in rma.lines.all()],
|
||||
1,
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Check that all line items have been received
|
||||
for line in rma.lines.all():
|
||||
self.assertTrue(line.received)
|
||||
self.assertIsNotNone(line.received_date)
|
||||
|
||||
# A single tracking entry should have been added to the item
|
||||
self.assertEqual(stock_item.tracking_info.count(), n_tracking + 1)
|
||||
|
||||
tracking_entry = stock_item.tracking_info.last()
|
||||
deltas = tracking_entry.deltas
|
||||
|
||||
self.assertEqual(deltas['status'], StockStatus.QUARANTINED)
|
||||
self.assertEqual(deltas['customer'], customer.pk)
|
||||
self.assertEqual(deltas['location'], 1)
|
||||
self.assertEqual(deltas['returnorder'], rma.pk)
|
||||
|
@ -16,6 +16,8 @@ class OrderViewTestCase(InvenTreeTestCase):
|
||||
'supplier_part',
|
||||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
'return_order',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -25,14 +27,17 @@ class OrderViewTestCase(InvenTreeTestCase):
|
||||
'sales_order.change',
|
||||
'sales_order.add',
|
||||
'sales_order.delete',
|
||||
'return_order.change',
|
||||
'return_order.add',
|
||||
'return_order.delete',
|
||||
]
|
||||
|
||||
|
||||
class OrderListTest(OrderViewTestCase):
|
||||
class PurchaseOrderListTest(OrderViewTestCase):
|
||||
"""Unit tests for the PurchaseOrder index page"""
|
||||
def test_order_list(self):
|
||||
"""Tests for the PurchaseOrder index page"""
|
||||
response = self.client.get(reverse('po-index'))
|
||||
response = self.client.get(reverse('purchase-order-index'))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -53,3 +58,31 @@ class PurchaseOrderTests(OrderViewTestCase):
|
||||
|
||||
# Response should be streaming-content (file download)
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
|
||||
class SalesOrderViews(OrderViewTestCase):
|
||||
"""Unit tests for the SalesOrder pages"""
|
||||
|
||||
def test_index(self):
|
||||
"""Test the SalesOrder index page"""
|
||||
response = self.client.get(reverse('sales-order-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_detail(self):
|
||||
"""Test SalesOrder detail view"""
|
||||
response = self.client.get(reverse('so-detail', args=(1,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ReturnOrderVIews(OrderViewTestCase):
|
||||
"""Unit tests for the ReturnOrder pages"""
|
||||
|
||||
def test_index(self):
|
||||
"""Test the ReturnOrder index page"""
|
||||
response = self.client.get(reverse('return-order-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_detail(self):
|
||||
"""Test ReturnOrder detail view"""
|
||||
response = self.client.get(reverse('return-order-detail', args=(1,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -4,7 +4,7 @@
|
||||
- Detail view of Purchase Orders
|
||||
"""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
@ -21,10 +21,10 @@ purchase_order_urls = [
|
||||
re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
|
||||
|
||||
# Display detail view for a single purchase order
|
||||
re_path(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
|
||||
path(r'<int:pk>/', include(purchase_order_detail_urls)),
|
||||
|
||||
# Display complete list of purchase orders
|
||||
re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'),
|
||||
re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'),
|
||||
]
|
||||
|
||||
sales_order_detail_urls = [
|
||||
@ -35,13 +35,23 @@ sales_order_detail_urls = [
|
||||
|
||||
sales_order_urls = [
|
||||
# Display detail view for a single SalesOrder
|
||||
re_path(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),
|
||||
path(r'<int:pk>/', include(sales_order_detail_urls)),
|
||||
|
||||
# Display list of all sales orders
|
||||
re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'),
|
||||
re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='sales-order-index'),
|
||||
]
|
||||
|
||||
|
||||
return_order_urls = [
|
||||
path(r'<int:pk>/', views.ReturnOrderDetail.as_view(), name='return-order-detail'),
|
||||
|
||||
# Display list of all return orders
|
||||
re_path(r'^.*$', views.ReturnOrderIndex.as_view(), name='return-order-index'),
|
||||
]
|
||||
|
||||
|
||||
order_urls = [
|
||||
re_path(r'^purchase-order/', include(purchase_order_urls)),
|
||||
re_path(r'^sales-order/', include(sales_order_urls)),
|
||||
re_path(r'^return-order/', include(return_order_urls)),
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -24,8 +24,8 @@ from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from . import forms as order_forms
|
||||
from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource
|
||||
from .models import (PurchaseOrder, PurchaseOrderLineItem, SalesOrder,
|
||||
SalesOrderLineItem)
|
||||
from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder,
|
||||
SalesOrder, SalesOrderLineItem)
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
@ -51,6 +51,14 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
|
||||
context_object_name = 'orders'
|
||||
|
||||
|
||||
class ReturnOrderIndex(InvenTreeRoleMixin, ListView):
|
||||
"""ReturnOrder index (list) view"""
|
||||
|
||||
model = ReturnOrder
|
||||
template_name = 'order/return_orders.html'
|
||||
context_object_name = 'orders'
|
||||
|
||||
|
||||
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
"""Detail view for a PurchaseOrder object."""
|
||||
|
||||
@ -67,6 +75,14 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView)
|
||||
template_name = 'order/sales_order_detail.html'
|
||||
|
||||
|
||||
class ReturnOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
"""Detail view for a ReturnOrder object"""
|
||||
|
||||
context_object_name = 'order'
|
||||
queryset = ReturnOrder.objects.all()
|
||||
template_name = 'order/return_order_detail.html'
|
||||
|
||||
|
||||
class PurchaseOrderUpload(FileManagementFormView):
|
||||
"""PurchaseOrder: Upload file, match to fields and parts (using multi-Step form)"""
|
||||
|
||||
|
Reference in New Issue
Block a user