Compare commits

...

139 Commits

Author SHA1 Message Date
Oliver
c621780a2a Merge pull request #748 from SchrodingersGat/sales-order
Sales order [WIP]
2020-04-27 22:14:45 +10:00
Oliver Walters
394a9208a1 Merge remote-tracking branch 'inventree/master' into sales-order
# Conflicts:
#	InvenTree/InvenTree/version.py
2020-04-27 22:04:05 +10:00
Oliver Walters
79836c77ef Bumped version thing 2020-04-27 22:03:15 +10:00
Oliver Walters
db9970e5df Add some further unit tests for the Build model 2020-04-27 21:44:59 +10:00
Oliver Walters
cb3fe0fc35 Add some more unit testing for the builds 2020-04-27 21:28:44 +10:00
Oliver Walters
35f48ed899 Delete BuildItem objects once a Build has been completed
- Much more complicated template for build allocation page!
- This will require some refactoring at some point ...
2020-04-27 20:46:34 +10:00
Oliver Walters
489dfa1823 Bug fix for a code path which resulted in a form failing validation but not showing any errors!
This one has been here for a while!
2020-04-27 20:45:01 +10:00
Oliver Walters
2b99cf353a Fix for build complete form 2020-04-27 20:16:41 +10:00
Oliver Walters
646dd65d27 Re-enable auto-allocation for build 2020-04-27 20:05:02 +10:00
Oliver Walters
3685ca4b95 Add some unit testing for the SalesOrder model 2020-04-27 11:32:20 +10:00
Oliver Walters
5e309a62f7 Display "Fulfilled" items
- Once a salesorder has been marked as "shipped" then the table is displayed differently
- The sub rows show stock items which have been fulfilled against the sales order
2020-04-27 10:31:38 +10:00
Oliver Walters
9b882f4d17 Update to latest version of django-qr-code 2020-04-27 09:17:17 +10:00
Oliver Walters
5167117067 Calculate 'fulfilled' quantity once a sales order is marked as shipped
- This allows us to delete the SalesOrderAllocation objects from the database
2020-04-27 08:58:18 +10:00
Oliver Walters
3c5ba75d27 PEP fixes 2020-04-26 16:44:35 +10:00
Oliver Walters
e768ada83b More work
- Consolidated "in_stock" filter to single code location
- Improve 'limit_choices_to' for BuildItem and SalesOrderAllocation
- Various template improvements etc
2020-04-26 16:38:29 +10:00
Oliver Walters
4147163418 Improve status code label rendering 2020-04-26 15:29:21 +10:00
Oliver
48c51151c8 Merge pull request #756 from SchrodingersGat/moar-migration-fixes-plz
Remove the problematic migration entirely
2020-04-26 09:00:23 +10:00
Oliver Walters
ae4717401f Add "sudo" to makefile 2020-04-26 08:56:36 +10:00
Oliver Walters
1f4bd95d75 Remove the problematic migration entirely
- The thumbnail check code is run every time the server is started anyway!
2020-04-26 08:50:37 +10:00
Oliver Walters
0892b160c6 "Fixes" for completing a build
- This will require a lot of unit testing to get right
2020-04-26 00:32:09 +10:00
Oliver Walters
72c43d0c2d Bug fix for build completion form 2020-04-25 23:59:28 +10:00
Oliver Walters
50dbebdf59 Improve rendering of BuildComplete template 2020-04-25 23:44:03 +10:00
Oliver Walters
81f789d857 Add link to parent build 2020-04-25 23:26:56 +10:00
Oliver Walters
4f0efec39f PEP and unit testing fixes 2020-04-25 23:24:00 +10:00
Oliver Walters
77361bd0a2 Update templates 2020-04-25 23:20:55 +10:00
Oliver Walters
01a68270ea Add verbose names for multiple Build model fields
- Improve methods for different models
2020-04-25 23:17:07 +10:00
Oliver Walters
181d1d6b91 Factor out function to calculate total allocations 2020-04-25 21:31:58 +10:00
Oliver Walters
912a3c4b99 Update progress bar based on sum of allocated quantities 2020-04-25 21:21:52 +10:00
Oliver Walters
b0891c921c Working towards better build allocation:
- Improve data serialization in API
- Javascript bug fixes
- Load the build allocation table using jQuery
2020-04-25 21:13:38 +10:00
Oliver Walters
c11b433d94 Make Build object into a MPTT tree model
- Builds can have child builds
2020-04-25 15:25:17 +10:00
Oliver Walters
2f0bbecc3d Refactored status code label generation
- Larger style available
2020-04-25 15:13:55 +10:00
Oliver Walters
4e40d92db7 PEP fix 2020-04-25 13:31:42 +10:00
Oliver Walters
66b6036827 Add two new "build orders" tab to the "sales order" view 2020-04-25 13:29:18 +10:00
Oliver Walters
d5f3498238 Add a "sales_order" reference to the Build model
- If a build order is made to fulfil a sales order
- Add sales_order filtering to the Build API
- Pass initial information through to the BuildCreate view
2020-04-25 13:15:45 +10:00
Oliver Walters
b351976ae9 Mark a SalesOrder as "shipped"
- Option to hide non-stock items from stock list
- Update models with new feature
2020-04-25 08:46:28 +10:00
Oliver Walters
c5b93e2392 Update pillow version
- Addresses a security patch requirement
2020-04-24 20:40:56 +10:00
Oliver Walters
41a4033427 Selective display of sales order ilne buttons 2020-04-24 17:18:18 +10:00
Oliver Walters
429bb688d0 Template fixes 2020-04-24 13:03:38 +10:00
Oliver Walters
9d25ed335c Rebuild the "PurchaseOrder" detail
- Use AJAX and bootstrap-table
- Display progress bar
2020-04-24 12:52:08 +10:00
Oliver Walters
ba1d2063af Remove some debug messages and fix PIP 2020-04-24 10:22:33 +10:00
Oliver Walters
77471cb89c Form for shipping a SalesOrder
- Returns "False" for now
2020-04-24 10:20:56 +10:00
Oliver Walters
b45fec221c Expose more status code data to the templates
- Status codes are now exposed globally to every page
- Much simplified so wow
- https://stackoverflow.com/questions/3221592/how-to-pass-common-dictionary-data-to-every-page-in-django
2020-04-24 09:27:42 +10:00
Oliver Walters
2c9b112562 PEP fixes 2020-04-23 21:48:39 +10:00
Oliver Walters
e5fa94b4f8 Add functionality to cancel a sales order 2020-04-23 21:38:40 +10:00
Oliver Walters
e384f9e94c Order date adjustment
Sales order now has a "shipment date"
2020-04-23 20:42:59 +10:00
Oliver Walters
435c13cf7c Separate concept of "OrderStatus" into "SalesOrderStatus" and "PurchaseOrderStatus" 2020-04-23 20:38:09 +10:00
Oliver Walters
7f020cbbf6 Enbiggen a whole lotta buttons 2020-04-23 19:41:14 +10:00
Oliver Walters
426aa9258c URL cleanup 2020-04-23 17:37:53 +10:00
Oliver Walters
5d71cf85cc Add separate 'quantity' and 'allocated' columns in sales order view 2020-04-23 16:00:09 +10:00
Oliver Walters
c9ea33e22e Fix order of javascript table events 2020-04-23 10:58:26 +10:00
Oliver Walters
d59c6711bb Update translations 2020-04-23 09:20:25 +10:00
Oliver Walters
1a0f091e0c Improve progress bar rendering 2020-04-23 09:20:18 +10:00
Oliver Walters
a803f21e0c Add buttons to create new builds or orders for sales order parts
- Need to pre-fill the forms a bit better
2020-04-22 23:34:27 +10:00
Oliver Walters
6112be2df0 Add forms for editing and deleting a SalesOrderAllocation item 2020-04-22 23:21:54 +10:00
Oliver Walters
2a4e903785 Add button to delete a SalesOrderLineItem 2020-04-22 22:36:55 +10:00
Oliver Walters
26d1a25f31 PEP style fixes 2020-04-22 22:24:06 +10:00
Oliver Walters
fd42149f67 More stuff 2020-04-22 22:22:22 +10:00
Oliver Walters
6ab03bd05a Add form for creating a new StockItem allocation 2020-04-22 21:26:38 +10:00
Oliver Walters
2972aec759 Cleverer rendering of sales order allocations 2020-04-22 20:26:05 +10:00
Oliver Walters
b70e79b778 Optionally add all SalesOrderAllocations to the SalesOrderLineItem serializer 2020-04-22 20:10:23 +10:00
Oliver Walters
5d1754ec32 Better display of where a StockItem is allocated 2020-04-22 17:39:59 +10:00
Oliver Walters
eb7b49784b StockItem serializer now includes the allocated quantity 2020-04-22 15:24:11 +10:00
Oliver Walters
d9698b10cc PEP fixes 2020-04-22 13:21:45 +10:00
Oliver Walters
1373425c29 Update definition for StockItemAllocation model
- Limit foreignkey choices
- Error checking
- Check if a StockItem is over-allocated
- Fix API serialization and filtering
2020-04-22 13:11:19 +10:00
Oliver Walters
2cb1b076f6 Create "SalesOrderAllocation" object
- Links multiple StockItem objects to a single SalesOrderLineItem
2020-04-22 12:12:48 +10:00
Oliver Walters
3a71a4f63a Fix for StockItem model
- Allow sales_order_line to be blank
2020-04-22 10:16:54 +10:00
Oliver Walters
6dd79af0b6 Expose "is_allocated" parameter on StockItem API 2020-04-22 10:11:40 +10:00
Oliver Walters
12daf15406 Update build page 2020-04-22 09:53:11 +10:00
Oliver Walters
372958d939 Migrate more pages to the two_column template 2020-04-22 09:50:10 +10:00
Oliver Walters
808a636484 Move "Company" view to new two-column template 2020-04-22 09:01:52 +10:00
Oliver Walters
79ea744280 Supplier part rendering fix 2020-04-21 22:39:47 +10:00
Oliver Walters
09ccd6c5e2 PEP style fixes 2020-04-21 22:37:35 +10:00
Oliver Walters
b75c343236 Add action buttons to the sales order page 2020-04-21 22:02:17 +10:00
Oliver Walters
15166c7797 Add a custom "id" to the progress bar 2020-04-21 21:43:04 +10:00
Oliver Walters
4979c690d9 Prevent BOM price calculation from becoming too recursive 2020-04-21 21:38:22 +10:00
Oliver Walters
cb636e000d Display a sub-list of stock items which are allocated to a SalseOrderLineItem 2020-04-21 21:38:04 +10:00
Oliver Walters
b40234e403 UI tweaks 2020-04-21 20:05:15 +10:00
Oliver Walters
399dcafede Use the existing bootstrap CSS for progress bars 2020-04-21 18:14:02 +10:00
Oliver Walters
2c6e8da90e Ability to filter StockItemList API by sales_order or sales_order_line 2020-04-21 17:33:02 +10:00
Oliver Walters
0d1919f10b Display an alert on a stock item page if that stock item is allocated to a salesorder 2020-04-21 16:59:14 +10:00
Oliver Walters
89ede3e103 Fix for SalesOrderLineItem allocation calculation
Also function to render a progress bar
2020-04-21 16:45:44 +10:00
Oliver Walters
a1376eeb9e The 'StockItem' model now has a reference to a SalesOrderLineItem 2020-04-21 15:04:21 +10:00
Oliver Walters
8052a1989c Serialize the allocated quantity for a purchase-order line item 2020-04-21 11:41:08 +10:00
Oliver Walters
7385099194 Add a model to map multiple StockItem objects to a single SalesOrderLineItem 2020-04-21 10:14:55 +10:00
Oliver Walters
19cd0707a2 Add / edit / delete attachments for SalesOrder 2020-04-21 09:42:11 +10:00
Oliver Walters
a06595c152 Add line numbering to SalesOrderLineItem table 2020-04-21 09:20:44 +10:00
Oliver Walters
22c96ad2b7 Add ability to filter SalesOrder list by part 2020-04-21 09:17:50 +10:00
Oliver Walters
3d2e907d5e Add a 'sales order' view for each part 2020-04-21 09:15:01 +10:00
Oliver Walters
b204618e79 Button / view / form to create a new SalesOrderLineItem 2020-04-21 09:02:10 +10:00
Oliver Walters
617fbf2f02 Moar stuffs:
- Expose part_detail and order_detail to SOLineItem serializer
- Update SalesOrder line item table
2020-04-21 08:57:13 +10:00
Oliver Walters
a2c0c7c76a Add "part" reference to SalesOrderLineItem model 2020-04-21 08:33:32 +10:00
Oliver Walters
34d3dca8b7 Add ability to filter parts by "purchasable" status 2020-04-20 22:40:52 +10:00
Oliver Walters
9e4d09343c Add ability to filter parts list by "salable" status 2020-04-20 22:39:00 +10:00
Oliver Walters
0c56079b41 Create missing tabs for sales orders
- Attachments
- Notes
2020-04-20 22:33:49 +10:00
Oliver Walters
e12824df2e Add form to edit a SalesOrder 2020-04-20 22:20:03 +10:00
Oliver Walters
ce1dd88129 Form for creating a new SalesOrder 2020-04-20 22:13:07 +10:00
Oliver Walters
ebbcff3c7f Render a table of line items 2020-04-20 21:22:34 +10:00
Oliver Walters
b2569d5cba Expose SalesOrderLineItem objects to the REST API 2020-04-20 21:11:59 +10:00
Oliver Walters
47ada25315 Add detail view for SalesOrder 2020-04-20 20:59:14 +10:00
Oliver Walters
1ebf26ab7c Add page for displaying all sales orders 2020-04-20 20:40:45 +10:00
Oliver Walters
627c50e465 Render a table of sales orders 2020-04-20 20:27:52 +10:00
Oliver Walters
c7fd22924f Register salesorder classes in the admin interface 2020-04-20 20:27:35 +10:00
Oliver Walters
9f97d81e83 API endpoint for serializing SalesOrder objects 2020-04-20 20:11:21 +10:00
Oliver Walters
5901b21e78 UI elements
- Add a "sales order" menu item to the main navbar
- Add a "sales order" tab to customer detail page
2020-04-20 19:47:29 +10:00
Oliver Walters
974c98c95a Add "SalesOrder" concept
- SalesOrder model
- SalesOrderLineItem
- SalesOrderAttachment
2020-04-20 19:41:58 +10:00
Oliver
c5166ec845 Update version.py 2020-04-20 19:30:58 +10:00
Oliver
13c7e2af49 Update version.py
Modify version number for release
2020-04-20 19:28:09 +10:00
Oliver
85e20041c7 Merge pull request #739 from SchrodingersGat/api-consolidation
Api consolidation
2020-04-20 09:55:16 +10:00
Oliver Walters
ff3cc96e0e PEP fixes 2020-04-20 09:50:41 +10:00
Oliver Walters
746e9ab983 Fix rendering of PurchaseOrder table 2020-04-20 09:48:33 +10:00
Oliver Walters
99fcbcc646 Consolidation of PurchaseOrder API 2020-04-20 09:41:21 +10:00
Oliver Walters
fee6246a8f PEP fix 2020-04-20 08:25:24 +10:00
Oliver Walters
4ec5e9a907 Consolidate StockItem serializer 2020-04-20 08:24:43 +10:00
Oliver Walters
ef66a3b8f3 Make PartDetail view same as PartList view 2020-04-20 08:10:59 +10:00
Oliver
654f5d348e Merge pull request #738 from SchrodingersGat/stock-count-fix
Stock count fix
2020-04-20 01:26:42 +10:00
Oliver Walters
f5c86bc457 "Fix" for unit tests
- Not working in travis for some reason?
- But they are working locally...
2020-04-20 01:23:05 +10:00
Oliver Walters
57fa69f6e6 Update version
- Display django version in "about" dialog
2020-04-20 01:20:09 +10:00
Oliver Walters
c72fce0cc5 Add "tracking items" back in 2020-04-20 01:14:19 +10:00
Oliver Walters
b2c40c91b7 PEP fixes 2020-04-20 01:11:25 +10:00
Oliver Walters
0334035e77 Simplify StockItem serializer
- Some more work needed here to cut down on database hits
2020-04-20 01:09:37 +10:00
Oliver Walters
4b1b9df193 Update stock table rendering 2020-04-20 01:02:19 +10:00
Oliver Walters
6a89e0089d Updates for stock serializer 2020-04-20 00:49:13 +10:00
Oliver Walters
5233281a24 Should have checked first :'(
New tablib is broked (at least in the admin interface)
2020-04-20 00:19:04 +10:00
Oliver Walters
468eba1759 Update PIP requirements
- django-mptt required updating to fix a bug where tree was not being rebuild on item save
2020-04-20 00:16:57 +10:00
Oliver Walters
ff91c4ec53 Add a new verision of django-qr-code
Official package does not (yet) support django 3.0
2020-04-20 00:10:16 +10:00
Oliver Walters
3a64d0bc8f Fix display of part table 2020-04-20 00:00:14 +10:00
Oliver Walters
092215918c PEP fixes 2020-04-19 23:56:16 +10:00
Oliver Walters
2621c51a7e Further API cleanup
- Perform a single call to get starred parts for current user and record results
- This provides significant speed improvements
- Remove old manual serializer
- More data prefetching
2020-04-19 23:50:41 +10:00
Oliver Walters
69b8eed028 Fixes for aggregation issues
- Ensure that "distinct=True" is set!
- ARRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
2020-04-19 22:54:46 +10:00
Oliver Walters
85d1c585c0 Update to django3!
- This is required to fix some issues with query aggregations as "distinct=True" cannot be set for Sum operations until django 3.0
- Multiple annotations on the same queryset were returning invalid results
- Ref: https://code.djangoproject.com/ticket/10060
- Ref: https://docs.djangoproject.com/en/3.0/topics/db/aggregation/#combining-multiple-aggregations
- django-dbbackup required updating for django3 support
- django-qr-code for now is disabled, a new solution needs to be worked out
2020-04-19 22:44:16 +10:00
Oliver
9cb1af9587 Merge pull request #737 from SchrodingersGat/doc-updates
Remove some docs pages
2020-04-18 14:55:04 +10:00
Oliver Walters
b580df0d30 Update README.md to point to the new docs 2020-04-18 14:37:51 +10:00
Oliver Walters
d953f1a31e Remove some docs pages
- These are now being consolidated on the inventree.github.io page
2020-04-18 14:09:14 +10:00
Oliver
a28b7df9d4 Merge pull request #736 from SchrodingersGat/config-file-fix
Small fix for config template
2020-04-18 13:26:43 +10:00
Oliver Walters
880655c141 Small fix for config template 2020-04-18 13:22:52 +10:00
Oliver
4f3f78f55a Merge pull request #735 from SchrodingersGat/token-auth-fix
Improvements for token authentication
2020-04-18 12:12:54 +10:00
Oliver Walters
6e3f603413 Improvements for token authentication
- Do not POST username and password data
- This is already encoded in the Authorization: Basic headers
2020-04-18 12:06:50 +10:00
Oliver
2b70b947ee Merge pull request #734 from SchrodingersGat/migration-fix
Add error checking for thumbnail file migration
2020-04-17 21:46:36 +10:00
Oliver Walters
37fcb810e4 Add error checking for thumbnail file migration 2020-04-17 19:23:43 +10:00
151 changed files with 8255 additions and 3139 deletions

View File

@@ -18,9 +18,10 @@ from .version import inventreeVersion, inventreeInstanceName
from plugins import plugins as inventree_plugins
# Load barcode plugins
print("INFO: Loading plugins")
print("Loading barcode plugins")
barcode_plugins = inventree_plugins.load_barcode_plugins()
print("Loading action plugins")
action_plugins = inventree_plugins.load_action_plugins()
@@ -136,7 +137,4 @@ class BarcodePluginView(APIView):
# Include the original barcode data
response['barcode_data'] = barcode_data
print("Response:")
print(response)
return Response(response)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
Provides extra global data to all templates.
"""
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
from InvenTree.status_codes import BuildStatus, StockStatus
def status_codes(request):
return {
# Expose the StatusCode classes to the templates
'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus,
'StockStatus': StockStatus,
}

View File

@@ -69,5 +69,7 @@ class RoundingDecimalField(models.DecimalField):
defaults = {
'form_class': RoundingDecimalFormField
}
defaults.update(kwargs)
return super(RoundingDecimalField, self).formfield(**kwargs)
return super().formfield(**kwargs)

View File

@@ -145,8 +145,10 @@ TEMPLATES = [
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'InvenTree.context.status_codes',
],
},
},
@@ -203,10 +205,12 @@ When running unit tests, enforce usage of sqlite3 database,
so that the tests can be run in RAM without any setup requirements
"""
if 'test' in sys.argv:
eprint('Running tests - Using sqlite3 memory database')
eprint('InvenTree: Running tests - Using sqlite3 memory database')
DATABASES['default'] = {
# Ensure sqlite3 backend is being used
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'test_db.sqlite3'
# Doesn't matter what the database is called, it is executed in RAM
'NAME': 'ram_test_db.sqlite3',
}
# Database backend selection

View File

@@ -3,6 +3,12 @@
--secondary-color: #b69c80;
--highlight-color: #f5efe8;
--basic-color: #333;
--label-red: #e35a57;
--label-blue: #4194bd;
--label-green: #50aa51;
--label-grey: #aaa;
--label-yellow: #fdc82a;
}
.markdownx .row {
@@ -29,6 +35,38 @@
padding: 10px;
}
/* Progress bars */
.progress {
position: relative;
width: 100%;
margin-bottom: 0px;
background: #eeeef5;
}
.progress-bar {
opacity: 60%;
background: #2aa02a;
}
.progress-bar-under {
background: #eeaa33;
}
.progress-bar-over {
background: #337ab7;
}
.progress-value {
width: 100%;
color: #333;
position: absolute;
text-align: center;
top: 0px;
left: 0px;
font-size: 110%;
}
.qr-code {
max-width: 400px;
max-height: 400px;
@@ -79,24 +117,20 @@
color: rgb(13, 245, 25);
}
.glyphicon-ok {
color: #5C5;
.icon-red {
color: #c55;
}
.glyphicon-ok-circle {
.icon-green {
color: #43bb43;
}
.icon-blue {
color: #55c;
}
.glyphicon-remove {
color: #C55;
}
.glyphicon-trash {
color: #C55;
}
.glyphicon-plus {
color: #5C5;
.icon-yellow {
color: #CC2;
}
/* CSS overrides for treeview */
@@ -121,6 +155,58 @@
.label-large {
margin: 3px;
font-size: 100%;
border: 3px solid;
border-radius: 15px;
background: none;
padding-right: 10px;
padding-left: 10px;
padding-top: 5px;
padding-bottom: 5px;
}
.label-large-red {
color: var(--label-red);
border-color: var(--label-red);
}
.label-red {
background: var(--label-red);
}
.label-large-blue {
color: var(--label-blue);
border-color: var(--label-blue);
}
.label-blue {
background: var(--label-blue);
}
.label-large-green {
color: var(--label-green);
border-color: var(--label-green);
}
.label-green {
background: var(--label-green);
}
.label-large-grey {
color: var(--label-grey);
border-color: var(--label-grey);
}
.label-grey {
background: var(--label-grey);
}
.label-large-yellow {
color: var(--label-yellow);
border-color: var(--label-yellow);
}
.label-yellow {
background: var(--label-yellow);
}
.label-right {
@@ -135,6 +221,15 @@
background-color: #ebf4f4;
}
.sub-table {
margin-left: 45px;
margin-right: 45px;
}
.detail-icon .glyphicon {
color: #98d296;
}
/* Force select2 elements in modal forms to be full width */
.select-full-width {
width: 100%;
@@ -248,7 +343,6 @@
/* dropzone class - for Drag-n-Drop file uploads */
.dropzone {
border: 1px solid #555;
z-index: 2;
}
@@ -290,6 +384,20 @@
padding-bottom: 2px;
}
.action-buttons .btn {
font-size: 175%;
align-content: center;
vertical-align: middle;
padding-left: 6px;
padding-right: 6px;
padding-top: 3px;
padding-bottom: 2px;
};
.panel-heading .badge {
float: right;
}
.badge {
float: right;
background-color: #777;
@@ -308,6 +416,8 @@
margin: 2px;
padding: 3px;
object-fit: contain;
border: 1px solid #aaa;
border-radius: 3px;
}
.part-thumb-container:hover .part-thumb-overlay {

View File

@@ -25,7 +25,6 @@ function inventreeGet(url, filters={}, options={}) {
dataType: 'json',
contentType: 'application/json',
success: function(response) {
console.log('Success GET data at ' + url);
if (options.success) {
options.success(response);
}
@@ -64,7 +63,6 @@ function inventreeFormDataUpload(url, data, options={}) {
processData: false,
contentType: false,
success: function(data, status, xhr) {
console.log('Form data upload success');
if (options.success) {
options.success(data, status, xhr);
}
@@ -97,7 +95,6 @@ function inventreePut(url, data={}, options={}) {
dataType: 'json',
contentType: 'application/json',
success: function(response, status) {
console.log(method + ' - ' + url + ' : result = ' + status);
if (options.success) {
options.success(response, status);
}
@@ -114,25 +111,3 @@ function inventreePut(url, data={}, options={}) {
}
});
}
// Return list of parts with optional filters
function getParts(filters={}, options={}) {
return inventreeGet('/api/part/', filters, options);
}
// Return list of part categories with optional filters
function getPartCategories(filters={}, options={}) {
return inventreeGet('/api/part/category/', filters, options);
}
function getCompanies(filters={}, options={}) {
return inventreeGet('/api/company/', filters, options);
}
function updateStockItem(pk, data, final=false) {
return inventreePut('/api/stock/' + pk + '/', data, final);
}
function updatePart(pk, data, final=false) {
return inventreePut('/api/part/' + pk + '/', data, final);
}

View File

@@ -221,7 +221,6 @@ function loadBomTable(table, options) {
}
}
});
}
// Part notes

View File

@@ -1,4 +1,5 @@
function loadBuildTable(table, options) {
// Display a table of Build objects
var params = options.params || {};

View File

@@ -18,6 +18,8 @@ function defaultFilters() {
build: "",
parts: "cascade=1",
company: "",
salesorder: "",
purchaseorder: "",
};
}

View File

@@ -78,6 +78,59 @@ function getImageUrlFromTransfer(transfer) {
return url;
}
function makeIconButton(icon, cls, pk, title) {
// Construct an 'icon button' using the fontawesome set
var classes = `btn btn-default btn-glyph ${cls}`;
var id = `${cls}-${pk}`;
var html = '';
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}'>`;
html += `<span class='fas ${icon}'></span>`;
html += `</button>`;
return html;
}
function makeProgressBar(value, maximum, opts) {
/*
* Render a progessbar!
*
* @param value is the current value of the progress bar
* @param maximum is the maximum value of the progress bar
*/
var options = opts || {};
value = parseFloat(value);
maximum = parseFloat(maximum);
var percent = parseInt(value / maximum * 100);
if (percent > 100) {
percent = 100;
}
var extraclass = '';
if (value > maximum) {
extraclass='progress-bar-over';
} else if (value < maximum) {
extraclass = 'progress-bar-under';
}
var id = options.id || 'progress-bar';
return `
<div id='${id}' class='progress'>
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
<div class='progress-value'>${value} / ${maximum}</div>
</div>
`;
}
function enableDragAndDrop(element, url, options) {
/* Enable drag-and-drop file uploading for a given element.

View File

@@ -104,21 +104,23 @@ function removePurchaseOrderLineItem(e) {
function loadPurchaseOrderTable(table, options) {
/* Create a purchase-order table */
var params = options.params || {};
options.params = options.params || {};
var filters = loadTableFilters("order");
options.params['supplier_detail'] = true;
for (var key in params) {
filters[key] = params[key];
var filters = loadTableFilters("purchaseorder");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("order", table);
setupFilterList("purchaseorder", $(table));
$(table).inventreeTable({
url: options.url,
queryParams: filters,
groupBy: false,
original: params,
original: options.params,
formatNoMatches: function() { return "No purchase orders found"; },
columns: [
{
@@ -131,21 +133,21 @@ function loadPurchaseOrderTable(table, options) {
field: 'reference',
title: 'Purchase Order',
formatter: function(value, row, index, field) {
return renderLink(value, "/order/purchase-order/" + row.pk + "/");
return renderLink(value, `/order/purchase-order/${row.pk}/`);
}
},
{
sortable: true,
field: 'supplier',
field: 'supplier_detail',
title: 'Supplier',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.supplier__image) + renderLink(row.supplier__name, '/company/' + value + '/purchase-orders/');
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
}
},
{
field: 'supplier_reference',
title: 'Supplier Reference',
sortable: true,
field: 'creation_date',
title: 'Date',
},
{
sortable: true,
@@ -157,12 +159,95 @@ function loadPurchaseOrderTable(table, options) {
field: 'status',
title: 'Status',
formatter: function(value, row, index, field) {
return orderStatusDisplay(row.status, row.status_text);
return purchaseOrderStatusDisplay(row.status, row.status_text);
}
},
{
sortable: true,
field: 'lines',
field: 'creation_date',
title: 'Date',
},
{
sortable: true,
field: 'line_items',
title: 'Items'
},
],
});
}
function loadSalesOrderTable(table, options) {
options.params = options.params || {};
options.params['customer_detail'] = true;
var filters = loadTableFilters("salesorder");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("salesorder", $(table));
$(table).inventreeTable({
url: options.url,
queryParams: filters,
groupBy: false,
original: options.params,
formatNoMatches: function() { return "No sales orders found"; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
sortable: true,
field: 'reference',
title: 'Sales Order',
formatter: function(value, row, index, field) {
return renderLink(value, `/order/sales-order/${row.pk}/`);
},
},
{
sortable: true,
field: 'customer_detail',
title: 'Customer',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
}
},
{
field: 'customer_reference',
title: 'Customer Reference',
sotrable: true,
},
{
sortable: true,
field: 'description',
title: 'Description',
},
{
sortable: true,
field: 'status',
title: 'Status',
formatter: function(value, row, index, field) {
return salesOrderStatusDisplay(row.status, row.status_text);
}
},
{
sortable: true,
field: 'creation_date',
title: 'Creation Date',
},
{
sortable: true,
field: 'shipment_date',
title: "Shipment Date",
},
{
sortable: true,
field: 'line_items',
title: 'Items'
},
],

View File

@@ -50,7 +50,7 @@ function toggleStar(options) {
{
method: 'POST',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star-empty').addClass('glyphicon-star');
$(options.button).addClass('icon-yellow');
},
}
);
@@ -64,7 +64,7 @@ function toggleStar(options) {
{
method: 'DELETE',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star').addClass('glyphicon-star-empty');
$(options.button).removeClass('icon-yellow');
},
}
);
@@ -87,6 +87,9 @@ function loadPartTable(table, url, options={}) {
* disableFilters: If true, disable custom filters
*/
// Ensure category detail is included
options.params['category_detail'] = true;
var params = options.params || {};
var filters = {};
@@ -155,6 +158,10 @@ function loadPartTable(table, url, options={}) {
display += `<span class='fas fa-star label-right' title='Starred part'></span>`;
}
if (row.salable) {
display += `<span class='fas fa-dollar-sign label-right' title='Salable part'></span>`;
}
/*
if (row.component) {
display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;
@@ -184,11 +191,11 @@ function loadPartTable(table, url, options={}) {
columns.push({
sortable: true,
field: 'category__name',
field: 'category_detail',
title: 'Category',
formatter: function(value, row, index, field) {
if (row.category) {
return renderLink(row.category__name, "/part/category/" + row.category + "/");
return renderLink(value.pathstring, "/part/category/" + row.category + "/");
}
else {
return 'No category';

View File

@@ -43,8 +43,12 @@ function loadStockTable(table, options) {
* filterList - <ul> element where filters are displayed
* disableFilters: If true, disable custom filters
*/
// List of user-params which override the default filters
options.params['part_detail'] = true;
options.params['location_detail'] = true;
var params = options.params || {};
var filterListElement = options.filterList || "#filter-list-stock";
@@ -83,27 +87,21 @@ function loadStockTable(table, options) {
var row = data[0];
if (field == 'part__name') {
if (field == 'part_name') {
var name = row.part__IPN;
var name = row.part_detail.full_name;
if (name) {
name += ' | ';
}
name += row.part__name;
return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
return imageHoverIcon(row.part_detail.thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
}
else if (field == 'part__description') {
return row.part__description;
else if (field == 'part_description') {
return row.part_detail.description;
}
else if (field == 'quantity') {
var stock = 0;
var items = 0;
data.forEach(function(item) {
stock += item.quantity;
stock += parseFloat(item.quantity);
items += 1;
});
@@ -216,39 +214,33 @@ function loadStockTable(table, options) {
visible: false,
},
{
field: 'part__name',
field: 'part_name',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
var name = row.part__IPN;
if (name) {
name += ' | ';
}
name += row.part__name;
if (row.part__revision) {
name += " | ";
name += row.part__revision;
}
var url = '';
var thumb = row.part_detail.thumbnail;
var name = row.part_detail.full_name;
if (row.supplier_part) {
url = `/supplier-part/${row.supplier_part}/`;
} else {
url = `/part/${row.part}/`;
}
html = imageHoverIcon(thumb) + renderLink(name, url);
return imageHoverIcon(row.part__thumbnail) + renderLink(name, url);
return html;
}
},
{
field: 'part__description',
field: 'part_description',
title: 'Description',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
},
{
field: 'quantity',
@@ -256,16 +248,27 @@ function loadStockTable(table, options) {
sortable: true,
formatter: function(value, row, index, field) {
var val = value;
var val = parseFloat(value);
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
val = '# ' + row.serial;
} else {
val = +val.toFixed(5);
}
var text = renderLink(val, '/stock/item/' + row.pk + '/');
var html = renderLink(val, `/stock/item/${row.pk}/`);
return text;
if (row.allocated) {
html += `<span class='fas fa-bookmark label-right' title='StockItem has been allocated'></span>`;
}
// 70 = "LOST"
if (row.status == 70) {
html += `<span class='fas fa-question-circle label-right' title='StockItem is lost'></span>`;
}
return html;
}
},
{
@@ -282,7 +285,7 @@ function loadStockTable(table, options) {
sortable: true,
},
{
field: 'location__path',
field: 'location_detail.pathstring',
title: 'Location',
sortable: true,
formatter: function(value, row, index, field) {

View File

@@ -7,12 +7,10 @@ class StatusCode:
This is used to map a set of integer values to text.
"""
labels = {}
@classmethod
def render(cls, key):
def render(cls, key, large=False):
"""
Render the value as a label.
Render the value as a HTML label.
"""
# If the key cannot be found, pass it back
@@ -20,12 +18,17 @@ class StatusCode:
return key
value = cls.options.get(key, key)
label = cls.labels.get(key, None)
color = cls.colors.get(key, 'grey')
if label:
return "<span class='label label-{label}'>{value}</span>".format(label=label, value=value)
if large:
span_class = 'label label-large label-large-{c}'.format(c=color)
else:
return value
span_class = 'label label-{c}'.format(c=color)
return "<span class='{cl}'>{value}</span>".format(
cl=span_class,
value=value
)
@classmethod
def list(cls):
@@ -42,10 +45,10 @@ class StatusCode:
'value': cls.options[key]
}
label = cls.labels.get(key)
color = cls.colors.get(key, None)
if label:
opt['label'] = label
if color:
opt['color'] = color
codes.append(opt)
@@ -70,11 +73,14 @@ class StatusCode:
raise ValueError("Label not found")
class OrderStatus(StatusCode):
class PurchaseOrderStatus(StatusCode):
"""
Defines a set of status codes for a PurchaseOrder
"""
# Order status codes
PENDING = 10 # Order is pending (not yet placed)
PLACED = 20 # Order has been placed
PLACED = 20 # Order has been placed with supplier
COMPLETE = 30 # Order has been completed
CANCELLED = 40 # Order was cancelled
LOST = 50 # Order was lost
@@ -89,13 +95,13 @@ class OrderStatus(StatusCode):
RETURNED: _("Returned"),
}
labels = {
PENDING: "primary",
PLACED: "primary",
COMPLETE: "success",
CANCELLED: "danger",
LOST: "warning",
RETURNED: "warning",
colors = {
PENDING: 'blue',
PLACED: 'blue',
COMPLETE: 'green',
CANCELLED: 'red',
LOST: 'yellow',
RETURNED: 'yellow',
}
# Open orders
@@ -112,6 +118,32 @@ class OrderStatus(StatusCode):
]
class SalesOrderStatus(StatusCode):
""" Defines a set of status codes for a SalesOrder """
PENDING = 10 # Order is pending
SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost
RETURNED = 60 # Order was returned
options = {
PENDING: _("Pending"),
SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"),
LOST: _("Lost"),
RETURNED: _("Returned"),
}
colors = {
PENDING: 'blue',
SHIPPED: 'green',
CANCELLED: 'red',
LOST: 'yellow',
RETURNED: 'yellow',
}
class StockStatus(StatusCode):
OK = 10 # Item is OK
@@ -119,6 +151,15 @@ class StockStatus(StatusCode):
DAMAGED = 55 # Item is damaged
DESTROYED = 60 # Item is destroyed
LOST = 70 # Item has been lost
RETURNED = 85 # Item has been returned from a customer
# Any stock code above 100 means that the stock item is not "in stock"
# This can be used as a quick check for filtering
NOT_IN_STOCK = 100
SHIPPED = 110 # Item has been shipped to a customer
ASSIGNED_TO_BUILD = 120
ASSIGNED_TO_OTHER_ITEM = 130
options = {
OK: _("OK"),
@@ -126,12 +167,20 @@ class StockStatus(StatusCode):
DAMAGED: _("Damaged"),
DESTROYED: _("Destroyed"),
LOST: _("Lost"),
RETURNED: _("Returned"),
SHIPPED: _('Shipped'),
ASSIGNED_TO_BUILD: _("Used for Build"),
ASSIGNED_TO_OTHER_ITEM: _("Installed in Stock Item")
}
labels = {
OK: 'success',
ATTENTION: 'warning',
DAMAGED: 'danger',
colors = {
OK: 'green',
ATTENTION: 'yellow',
DAMAGED: 'red',
DESTROYED: 'red',
SHIPPED: 'green',
ASSIGNED_TO_BUILD: 'blue',
ASSIGNED_TO_OTHER_ITEM: 'blue',
}
# The following codes correspond to parts that are 'available' or 'in stock'
@@ -139,12 +188,16 @@ class StockStatus(StatusCode):
OK,
ATTENTION,
DAMAGED,
RETURNED,
]
# The following codes correspond to parts that are 'unavailable'
UNAVAILABLE_CODES = [
DESTROYED,
LOST,
SHIPPED,
ASSIGNED_TO_BUILD,
ASSIGNED_TO_OTHER_ITEM,
]
@@ -163,11 +216,11 @@ class BuildStatus(StatusCode):
COMPLETE: _("Complete"),
}
labels = {
PENDING: 'primary',
ALLOCATED: 'info',
COMPLETE: 'success',
CANCELLED: 'danger',
colors = {
PENDING: 'blue',
ALLOCATED: 'blue',
COMPLETE: 'green',
CANCELLED: 'red',
}
ACTIVE_CODES = [

View File

@@ -4,9 +4,10 @@ from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from base64 import b64encode
class APITests(APITestCase):
""" Tests for the InvenTree API """
@@ -21,24 +22,48 @@ class APITests(APITestCase):
username = 'test_user'
password = 'test_pass'
token = None
def setUp(self):
# Create a user (but do not log in!)
User = get_user_model()
User.objects.create_user(self.username, 'user@email.com', self.password)
def get_token(self):
def basicAuth(self):
# Use basic authentication
authstring = bytes("{u}:{p}".format(u=self.username, p=self.password), "ascii")
# Use "basic" auth by default
auth = b64encode(authstring).decode("ascii")
self.client.credentials(HTTP_AUTHORIZATION="Basic {auth}".format(auth=auth))
def tokenAuth(self):
self.basicAuth()
token_url = reverse('api-token')
response = self.client.get(token_url, format='json', data={})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('token', response.data)
# POST to retreive a token
response = self.client.post(token_url, format='json', data={'username': self.username, 'password': self.password})
token = response.data['token']
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
self.token = token
def token_failure(self):
# Test token endpoint without basic auth
url = reverse('api-token')
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertIsNone(self.token)
def token_success(self):
self.tokenAuth()
self.assertIsNotNone(self.token)
def test_info_view(self):
"""
Test that we can read the 'info-view' endpoint.
@@ -55,51 +80,18 @@ class APITests(APITestCase):
self.assertEquals('InvenTree', data['server'])
def test_get_token_fail(self):
""" Ensure that an invalid user cannot get a token """
token_url = reverse('api-token')
response = self.client.post(token_url, format='json', data={'username': 'bad', 'password': 'also_bad'})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse('token' in response.data)
def test_get_token_pass(self):
""" Ensure that a valid user can request an API token """
token_url = reverse('api-token')
# POST to retreive a token
response = self.client.post(token_url, format='json', data={'username': self.username, 'password': self.password})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue('token' in response.data)
self.assertTrue('pk' in response.data)
self.assertTrue(len(response.data['token']) > 0)
# Now, use the token to access other data
token = response.data['token']
part_url = reverse('api-part-list')
# Try to access without a token
response = self.client.get(part_url, format='json')
def test_barcode_fail(self):
# Test barcode endpoint without auth
response = self.client.post(reverse('api-barcode-plugin'), format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# Now, with the token
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
response = self.client.get(part_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_barcode(self):
""" Test the barcode endpoint """
url = reverse('api-barcode-plugin')
self.tokenAuth()
self.get_token()
url = reverse('api-barcode-plugin')
data = {
'barcode': {

View File

@@ -25,7 +25,7 @@ from part.api import part_api_urls, bom_api_urls
from company.api import company_api_urls
from stock.api import stock_api_urls
from build.api import build_api_urls
from order.api import po_api_urls
from order.api import order_api_urls
from django.conf import settings
from django.conf.urls.static import static
@@ -49,7 +49,7 @@ apipatterns = [
url(r'^company/', include(company_api_urls)),
url(r'^stock/', include(stock_api_urls)),
url(r'^build/', include(build_api_urls)),
url(r'^po/', include(po_api_urls)),
url(r'^order/', include(order_api_urls)),
# User URLs
url(r'^user/', include(user_urls)),
@@ -73,11 +73,17 @@ settings_urls = [
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings'),
]
dynamic_javascript_urls = [
]
urlpatterns = [
url(r'^part/', include(part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^price-break/', include(price_break_urls)),
# "Dynamic" javascript files which are rendered using InvenTree templating.
url(r'^dynamic/', include(dynamic_javascript_urls)),
url(r'^common/', include(common_urls)),
url(r'^stock/', include(stock_urls)),

View File

@@ -4,8 +4,9 @@ Provides information on the current InvenTree version
import subprocess
from common.models import InvenTreeSetting
import django
INVENTREE_SW_VERSION = "0.0.11_pre"
INVENTREE_SW_VERSION = "0.1.0 pre"
def inventreeInstanceName():
@@ -18,6 +19,11 @@ def inventreeVersion():
return INVENTREE_SW_VERSION
def inventreeDjangoVersion():
""" Return the version of Django library """
return django.get_version()
def inventreeCommitHash():
""" Returns the git commit hash for the running codebase """

View File

@@ -38,6 +38,7 @@ class BuildList(generics.ListCreateAPIView):
]
filter_fields = [
'sales_order',
]
def get_queryset(self):
@@ -46,21 +47,27 @@ class BuildList(generics.ListCreateAPIView):
as some of the fields don't natively play nicely with DRF
"""
build_list = super().get_queryset()
queryset = super().get_queryset().prefetch_related('part')
# Filter by part
part = self.request.query_params.get('part', None)
return queryset
def filter_queryset(self, queryset):
if part is not None:
build_list = build_list.filter(part=part)
queryset = super().filter_queryset(queryset)
# Filter by build status?
status = self.request.query_params.get('status', None)
if status is not None:
build_list = build_list.filter(status=status)
queryset = queryset.filter(status=status)
return build_list
# Filter by associated part?
part = self.request.query_params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
return queryset
def get_serializer(self, *args, **kwargs):
@@ -99,20 +106,25 @@ class BuildItemList(generics.ListCreateAPIView):
to allow filtering by stock_item.part
"""
# Does the user wish to filter by part?
part_pk = self.request.query_params.get('part', None)
query = BuildItem.objects.all()
query = query.select_related('stock_item')
query = query.prefetch_related('stock_item__part')
query = query.prefetch_related('stock_item__part__category')
if part_pk:
query = query.filter(stock_item__part=part_pk)
return query
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
# Does the user wish to filter by part?
part_pk = self.request.query_params.get('part', None)
if part_pk:
queryset = queryset.filter(stock_item__part=part_pk)
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
@@ -132,7 +144,7 @@ build_item_api_urls = [
]
build_api_urls = [
url(r'^item/?', include(build_item_api_urls)),
url(r'^item/', include(build_item_api_urls)),
url(r'^(?P<pk>\d+)/', BuildDetail.as_view(), name='api-build-detail'),

View File

@@ -10,6 +10,10 @@
status: 10 # PENDING
creation_date: '2019-03-16'
link: http://www.google.com
level: 0
lft: 0
rght: 0
tree_id: 0
- model: build.build
fields:
@@ -19,4 +23,8 @@
status: 40 # COMPLETE
quantity: 21
notes: 'Some more simple notes'
creation_date: '2019-03-16'
creation_date: '2019-03-16'
level: 0
lft: 0
rght: 0
tree_id: 1

View File

@@ -22,6 +22,8 @@ class EditBuildForm(HelperForm):
fields = [
'title',
'part',
'parent',
'sales_order',
'quantity',
'take_from',
'batch',

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-24 22:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0029_auto_20200423_1042'),
('build', '0011_auto_20200406_0123'),
]
operations = [
migrations.AddField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder'),
),
]

View File

@@ -0,0 +1,55 @@
# Generated by Django 3.0.5 on 2020-04-25 05:07
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
from build.models import Build
def update_tree(apps, schema_editor):
# Update the Build MPTT model
Build.objects.rebuild()
def nupdate_tree(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('build', '0012_build_sales_order'),
]
operations = [
migrations.AddField(
model_name='build',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build'),
),
migrations.AddField(
model_name='build',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='build',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=nupdate_tree),
]

View File

@@ -0,0 +1,71 @@
# Generated by Django 3.0.5 on 2020-04-25 12:43
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('stock', '0031_auto_20200422_0209'),
('order', '0029_auto_20200423_1042'),
('build', '0013_auto_20200425_0507'),
]
operations = [
migrations.AlterField(
model_name='build',
name='batch',
field=models.CharField(blank=True, help_text='Batch code for this build output', max_length=100, null=True, verbose_name='Batch Code'),
),
migrations.AlterField(
model_name='build',
name='link',
field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', verbose_name='External Link'),
),
migrations.AlterField(
model_name='build',
name='notes',
field=markdownx.models.MarkdownxField(blank=True, help_text='Extra build notes', verbose_name='Notes'),
),
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
migrations.AlterField(
model_name='build',
name='part',
field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'active': True, 'assembly': True, 'is_template': False, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.Part', verbose_name='Part'),
),
migrations.AlterField(
model_name='build',
name='quantity',
field=models.PositiveIntegerField(default=1, help_text='Number of parts to build', validators=[django.core.validators.MinValueValidator(1)], verbose_name='Build Quantity'),
),
migrations.AlterField(
model_name='build',
name='sales_order',
field=models.ForeignKey(blank=True, help_text='SalesOrder to which this build is allocated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='order.SalesOrder', verbose_name='Sales Order Reference'),
),
migrations.AlterField(
model_name='build',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Allocated'), (30, 'Cancelled'), (40, 'Complete')], default=10, help_text='Build status code', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Build Status'),
),
migrations.AlterField(
model_name='build',
name='take_from',
field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation', verbose_name='Source Location'),
),
migrations.AlterField(
model_name='build',
name='title',
field=models.CharField(help_text='Brief description of the build', max_length=100, verbose_name='Build Title'),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.0.5 on 2020-04-25 13:50
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('build', '0014_auto_20200425_1243'),
]
operations = [
migrations.AlterField(
model_name='build',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Parent build to which this build is allocated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='build.Build', verbose_name='Parent Build'),
),
migrations.AlterField(
model_name='builditem',
name='quantity',
field=models.DecimalField(decimal_places=5, default=1, help_text='Stock quantity to allocate to build', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 05:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0033_auto_20200426_0539'),
('build', '0015_auto_20200425_1350'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 06:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0034_auto_20200426_0602'),
('build', '0016_auto_20200426_0551'),
]
operations = [
migrations.AlterField(
model_name='builditem',
name='stock_item',
field=models.ForeignKey(help_text='Stock Item to allocate to build', limit_choices_to={'belongs_to': None, 'build_order': None, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='stock.StockItem'),
),
]

View File

@@ -14,11 +14,14 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models, transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from markdownx.models import MarkdownxField
from InvenTree.status_codes import BuildStatus
from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string
@@ -26,13 +29,15 @@ from stock.models import StockItem
from part.models import Part, BomItem
class Build(models.Model):
class Build(MPTTModel):
""" A Build object organises the creation of new parts from the component parts.
Attributes:
part: The part to be built (from component BOM items)
title: Brief title describing the build (required)
quantity: Number of units to be built
parent: Reference to a Build object for which this Build is required
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
status: Build status code
batch: Batch code transferred to build parts (optional)
@@ -43,60 +48,102 @@ class Build(models.Model):
"""
def __str__(self):
return "Build {q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part))
return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name))
def get_absolute_url(self):
return reverse('build-detail', kwargs={'pk': self.id})
title = models.CharField(
verbose_name=_('Build Title'),
blank=False,
max_length=100,
help_text=_('Brief description of the build'))
help_text=_('Brief description of the build')
)
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={
'is_template': False,
'assembly': True,
'active': True,
'virtual': False,
},
help_text=_('Select part to build'),
)
parent = TreeForeignKey(
'self',
on_delete=models.DO_NOTHING,
blank=True, null=True,
related_name='children',
verbose_name=_('Parent Build'),
help_text=_('Parent build to which this build is allocated'),
)
part = models.ForeignKey(
'part.Part',
verbose_name=_('Part'),
on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={
'is_template': False,
'assembly': True,
'active': True,
'virtual': False,
},
help_text=_('Select part to build'),
)
sales_order = models.ForeignKey(
'order.SalesOrder',
verbose_name=_('Sales Order Reference'),
on_delete=models.SET_NULL,
related_name='builds',
null=True, blank=True,
help_text=_('SalesOrder to which this build is allocated')
)
take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
related_name='sourcing_builds',
null=True, blank=True,
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
)
take_from = models.ForeignKey(
'stock.StockLocation',
verbose_name=_('Source Location'),
on_delete=models.SET_NULL,
related_name='sourcing_builds',
null=True, blank=True,
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
)
quantity = models.PositiveIntegerField(
verbose_name=_('Build Quantity'),
default=1,
validators=[MinValueValidator(1)],
help_text=_('Number of parts to build')
)
status = models.PositiveIntegerField(default=BuildStatus.PENDING,
choices=BuildStatus.items(),
validators=[MinValueValidator(0)],
help_text=_('Build status'))
status = models.PositiveIntegerField(
verbose_name=_('Build Status'),
default=BuildStatus.PENDING,
choices=BuildStatus.items(),
validators=[MinValueValidator(0)],
help_text=_('Build status code')
)
batch = models.CharField(max_length=100, blank=True, null=True,
help_text=_('Batch code for this build output'))
batch = models.CharField(
verbose_name=_('Batch Code'),
max_length=100,
blank=True,
null=True,
help_text=_('Batch code for this build output')
)
creation_date = models.DateField(auto_now_add=True, editable=False)
completion_date = models.DateField(null=True, blank=True)
completed_by = models.ForeignKey(User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='builds_completed'
)
completed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='builds_completed'
)
link = InvenTreeURLField(blank=True, help_text=_('Link to external URL'))
link = InvenTreeURLField(
verbose_name=_('External Link'),
blank=True, help_text=_('Link to external URL')
)
notes = MarkdownxField(blank=True, help_text=_('Extra build notes'))
notes = MarkdownxField(
verbose_name=_('Notes'),
blank=True, help_text=_('Extra build notes')
)
@property
def output_count(self):
@@ -214,32 +261,20 @@ class Build(models.Model):
- Delete pending BuildItem objects
"""
for item in self.allocated_stock.all().prefetch_related('stock_item'):
# Subtract stock from the item
item.stock_item.take_stock(
item.quantity,
user,
'Removed {n} items to build {m} x {part}'.format(
n=item.quantity,
m=self.quantity,
part=self.part.full_name
)
)
# Complete the build allocation for each BuildItem
for build_item in self.allocated_stock.all().prefetch_related('stock_item'):
build_item.complete_allocation(user)
# Delete the item
item.delete()
# Mark the date of completion
self.completion_date = datetime.now().date()
self.completed_by = user
# Check that the stock-item has been assigned to this build, and remove the builditem from the database
if build_item.stock_item.build_order == self:
build_item.delete()
notes = 'Built {q} on {now}'.format(
q=self.quantity,
now=str(datetime.now().date())
)
# Generate the build outputs
if self.part.trackable and serial_numbers:
# Add new serial numbers
for serial in serial_numbers:
@@ -269,31 +304,54 @@ class Build(models.Model):
item.save()
# Finally, mark the build as complete
self.completion_date = datetime.now().date()
self.completed_by = user
self.status = BuildStatus.COMPLETE
self.save()
return True
def isFullyAllocated(self):
"""
Return True if this build has been fully allocated.
"""
bom_items = self.part.bom_items.all()
for item in bom_items:
part = item.sub_part
if not self.isPartFullyAllocated(part):
return False
return True
def isPartFullyAllocated(self, part):
"""
Check if a given Part is fully allocated for this Build
"""
return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part)
def getRequiredQuantity(self, part):
""" Calculate the quantity of <part> required to make this build.
"""
try:
item = BomItem.objects.get(part=self.part.id, sub_part=part.id)
return item.get_required_quantity(self.quantity)
q = item.quantity
except BomItem.DoesNotExist:
return 0
q = 0
return q * self.quantity
def getAllocatedQuantity(self, part):
""" Calculate the total number of <part> currently allocated to this build
"""
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(Sum('quantity'))
allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0))
q = allocated['quantity__sum']
if q:
return int(q)
else:
return 0
return allocated['q']
def getUnallocatedQuantity(self, part):
""" Calculate the quantity of <part> which still needs to be allocated to this build.
@@ -313,11 +371,12 @@ class Build(models.Model):
parts = []
for item in self.part.bom_items.all().prefetch_related('sub_part'):
part = {'part': item.sub_part,
'per_build': item.quantity,
'quantity': item.quantity * self.quantity,
'allocated': self.getAllocatedQuantity(item.sub_part)
}
part = {
'part': item.sub_part,
'per_build': item.quantity,
'quantity': item.quantity * self.quantity,
'allocated': self.getAllocatedQuantity(item.sub_part)
}
parts.append(part)
@@ -393,15 +452,39 @@ class BuildItem(models.Model):
q=self.stock_item.quantity
))]
except StockItem.DoesNotExist:
pass
if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity:
errors['quantity'] = _('StockItem is over-allocated')
except Part.DoesNotExist:
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.stock_item.serial and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock')
except (StockItem.DoesNotExist, Part.DoesNotExist):
pass
if len(errors) > 0:
raise ValidationError(errors)
def complete_allocation(self, user):
item = self.stock_item
# Split the allocated stock if there are more available than allocated
if item.quantity > self.quantity:
item = item.splitStock(self.quantity, None, user)
# Update our own reference to the new item
self.stock_item = item
self.save()
# TODO - If the item__part object is not trackable, delete the stock item here
item.status = StockStatus.ASSIGNED_TO_BUILD
item.build_order = self.build
item.save()
build = models.ForeignKey(
Build,
on_delete=models.CASCADE,
@@ -414,12 +497,17 @@ class BuildItem(models.Model):
on_delete=models.CASCADE,
related_name='allocations',
help_text=_('Stock Item to allocate to build'),
limit_choices_to={
'build_order': None,
'sales_order': None,
'belongs_to': None,
}
)
quantity = models.DecimalField(
decimal_places=5,
max_digits=15,
default=1,
validators=[MinValueValidator(1)],
validators=[MinValueValidator(0)],
help_text=_('Stock quantity to allocate to build')
)

View File

@@ -21,6 +21,8 @@ class BuildSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
quantity = serializers.FloatField()
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@@ -39,6 +41,7 @@ class BuildSerializer(InvenTreeModelSerializer):
'completion_date',
'part',
'part_detail',
'sales_order',
'quantity',
'status',
'status_text',
@@ -62,6 +65,8 @@ class BuildItemSerializer(InvenTreeModelSerializer):
part_image = serializers.CharField(source='stock_item.part.image', read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
quantity = serializers.FloatField()
class Meta:
model = BuildItem
fields = [

View File

@@ -1,5 +1,6 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
@@ -10,39 +11,398 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %}
{% if editing %}
{% include "build/allocate_edit.html" %}
{% else %}
{% include "build/allocate_view.html" %}
{% endif %}
<div id='build-item-toolbar'>
{% if build.status == BuildStatus.PENDING %}
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
<button class='btn btn-primary' type='button' id='btn-allocate' title='{% trans "Automatically allocate stock" %}'>{% trans "Auto Allocate" %}</button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='Unallocate Stock'>{% trans "Unallocate" %}</button>
</div>
{% endif %}
</div>
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script src="{% static 'script/inventree/part.js' %}"></script>
<script src="{% static 'script/inventree/build.js' %}"></script>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if editing %}
var buildTable = $("#build-item-list");
{% for bom_item in bom_items.all %}
// Calculate sum of allocations for a particular table row
function sumAllocations(row) {
if (row.allocations == null) {
return 0;
}
loadAllocationTable(
$("#allocate-table-id-{{ bom_item.sub_part.id }}"),
{{ bom_item.sub_part.id }},
"{{ bom_item.sub_part.full_name }}",
"{% url 'api-build-item-list' %}?build={{ build.id }}&part={{ bom_item.sub_part.id }}",
{% multiply build.quantity bom_item.quantity %},
$("#new-item-{{ bom_item.sub_part.id }}")
);
var quantity = 0;
{% endfor %}
row.allocations.forEach(function(item) {
quantity += item.quantity;
});
$("#auto-allocate-build").on('click', function() {
return quantity;
}
function getUnallocated(row) {
// Return the number of items remaining to be allocated for a given row
return {{ build.quantity }} * row.quantity - sumAllocations(row);
}
function reloadTable() {
// Reload the build allocation table
buildTable.bootstrapTable('refresh');
}
function setupCallbacks() {
// Register button callbacks once the table data are loaded
buildTable.find(".button-add").click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm('/build/item/new/', {
success: reloadTable,
data: {
part: row.sub_part,
build: {{ build.id }},
quantity: getUnallocated(row),
},
secondary: [
{
field: 'stock_item',
label: '{% trans "New Stock Item" %}',
title: '{% trans "Create new Stock Item"',
url: '{% url "stock-item-create" %}',
data: {
part: row.sub_part,
},
},
]
});
});
buildTable.find(".button-build").click(function() {
// Start a new build for the sub_part
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm('/build/new/', {
follow: true,
data: {
part: row.sub_part,
parent: {{ build.id }},
quantity: getUnallocated(row),
},
});
});
buildTable.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = buildTable.bootstrapTable('getData')[idx];
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [row.sub_part],
},
});
});
}
buildTable.inventreeTable({
uniqueId: 'sub_part',
url: "{% url 'api-bom-list' %}",
onPostBody: setupCallbacks,
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
return row.allocations != null;
},
detailFormatter: function(index, row, element) {
// Construct an 'inner table' which shows the stock allocations
var subTableId = `allocation-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
element.html(html);
var lineItem = row;
var subTable = $(`#${subTableId}`);
subTable.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'quantity',
title: 'Quantity',
formatter: function(value, row, index, field) {
var text = '';
var url = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
{% if build.status == BuildStatus.COMPLETE %}
url = `/stock/item/${row.pk}/`;
{% else %}
url = `/stock/item/${row.stock_item}/`;
{% endif %}
return renderLink(text, url);
},
},
{
field: 'location',
title: '{% trans "Location" %}',
formatter: function(value, row, index, field) {
{% if build.status == BuildStatus.COMPLETE %}
var text = row.location_detail.pathstring;
var url = `/stock/location/${row.location}/`;
{% else %}
var text = row.stock_item_detail.location_name;
var url = `/stock/location/${row.stock_item_detail.location}/`;
{% endif %}
return renderLink(text, url);
}
},
{% if build.status == BuildStatus.PENDING %}
{
field: 'buttons',
title: 'Actions',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
{% if build.status == BuildStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += `</div>`;
return html;
},
},
{% endif %}
]
});
// Assign button callbacks to the newly created allocation buttons
subTable.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/edit/`, {
success: reloadTable,
});
});
subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/item/${pk}/delete/`, {
success: reloadTable,
});
});
},
formatNoMatches: function() { return "{% trans 'No BOM items found' %}"; },
onLoadSuccess: function(tableData) {
// Once the BOM data are loaded, request allocation data for the build
{% if build.status == BuildStatus.COMPLETE %}
// Request StockItem which have been assigned to this build
inventreeGet('/api/stock/',
{
build_order: {{ build.id }},
location_detail: true,
},
{
success: function(data) {
// Iterate through the returned data, group by "part",
var allocations = {};
data.forEach(function(item) {
// Group allocations by referenced 'part'
var key = parseInt(item.part);
if (!(key in allocations)) {
allocations[key] = new Array();
}
allocations[key].push(item);
});
for (var key in allocations) {
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
tableRow.allocations = allocations[key];
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
}
},
},
);
{% else %}
inventreeGet('/api/build/item/',
{
build: {{ build.id }},
},
{
success: function(data) {
// Iterate through the returned data, and group by "part"
var allocations = {};
data.forEach(function(item) {
// Group allocations by referenced 'part'
var part = item.part;
var key = parseInt(part);
if (!(key in allocations)) {
allocations[key] = new Array();
}
// Add the allocation to the list
allocations[key].push(item);
});
for (var key in allocations) {
// Select the associated row in the table
var tableRow = buildTable.bootstrapTable('getRowByUniqueId', key);
// Set the allocations for the row
tableRow.allocations = allocations[key];
// And push the updated row back into the main table
buildTable.bootstrapTable('updateByUniqueId', key, tableRow, true);
}
}
},
);
{% endif %}
},
queryParams: {
part: {{ build.part.id }},
sub_part_detail: 1,
},
columns: [
{
field: 'id',
visible: false,
},
{
sortable: true,
field: 'sub_part',
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
return imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, `/part/${row.sub_part}/`);
},
},
{
sortable: true,
field: 'sub_part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Required" %}',
formatter: function(value, row) {
return value * {{ build.quantity }};
},
},
{
sortable: true,
field: 'allocated',
{% if build.status == BuildStatus.COMPLETE %}
title: '{% trans "Assigned" %}',
{% else %}
title: '{% trans "Allocated" %}',
{% endif %}
formatter: function(value, row) {
var allocated = sumAllocations(row);
return makeProgressBar(allocated, row.quantity * {{ build.quantity }});
},
sorter: function(valA, valB, rowA, rowB) {
var aA = sumAllocations(rowA);
var aB = sumAllocations(rowB);
var qA = rowA.quantity * {{ build.quantity }};
var qB = rowB.quantity * {{ build.quantity }};
if (aA == 0 && aB == 0) {
return (qA > qB) ? 1 : -1;
}
var progressA = parseFloat(aA) / qA;
var progressB = parseFloat(aB) / qB;
return (progressA < progressB) ? 1 : -1;
}
},
{% if build.status == BuildStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.sub_part;
{% if build.status == BuildStatus.PENDING %}
if (row.sub_part_detail.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', pk, '{% trans "Buy parts" %}');
}
if (row.sub_part_detail.assembly) {
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
}
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}');
{% endif %}
html += '</div>';
return html;
},
}
{% endif %}
],
});
{% if build.status == BuildStatus.PENDING %}
$("#btn-allocate").on('click', function() {
launchModalForm(
"{% url 'build-auto-allocate' build.id %}",
{
@@ -50,8 +410,8 @@ InvenTree | Allocate Parts
}
);
});
$('#unallocate-build').on('click', function() {
$('#btn-unallocate').on('click', function() {
launchModalForm(
"{% url 'build-unallocate' build.id %}",
{
@@ -59,16 +419,7 @@ InvenTree | Allocate Parts
}
);
});
{% else %}
$("#build-list").inventreeTable({
});
$("#btn-allocate").click(function() {
location.href = "{% url 'build-allocate' build.id %}?edit=1";
});
$("#btn-order-parts").click(function() {
launchModalForm("/order/purchase-order/order-parts/", {
data: {
@@ -76,7 +427,8 @@ InvenTree | Allocate Parts
},
});
});
{% endif %}
{% endblock %}

View File

@@ -1,34 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<div class='row'>
<h4>{% trans "Allocate Stock to Build" %}</h4>
<div class='col-sm-6'>
</div>
<div class='col-sm-6'>
<div class='btn-group' style='float: right;'>
<button class='btn btn-primary' type='button' title='Automatic allocation' id='auto-allocate-build'>{% trans "Auto Allocate" %}</button>
<button class='btn btn-warning' type='button' title='Unallocate build stock' id='unallocate-build'>{% trans "Unallocate" %}</button>
</div>
</div>
</div>
<hr>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Part" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Available" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Required" %}</h4>
</div>
<div class='col-sm-2'>
<h4>{% trans "Allocated" %}</h4>
</div>
</div>
{% for bom_item in bom_items.all %}
{% include "build/allocation_item.html" with item=bom_item build=build collapse_id=bom_item.id %}
{% endfor %}

View File

@@ -1,40 +0,0 @@
{% load i18n %}
{% load inventree_extras %}
<h4>{% trans "Required Parts" %}</h4>
<hr>
<div id='build-item-toolbar'>
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-allocate' title='Allocate Stock'>{% trans "Allocate" %}</button>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'>{% trans "Order Parts" %}</button>
</div>
</div>
<table class='table table-striped table-condensed' id='build-list' data-sorting='true' data-toolbar='#build-item-toolbar'>
<thead>
<tr>
<th data-sortable='true'>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th data-sortable='true'>{% trans "Available" %}</th>
<th data-sortable='true'>{% trans "Required" %}</th>
<th data-sortable='true'>{% trans "Allocated" %}</th>
<th data-sortable='true'>{% trans "On Order" %}</th>
</tr>
</thead>
<tbody>
{% for item in build.required_parts %}
<tr {% if build.status == BuildStatus.PENDING %}class='{% if item.part.total_stock > item.quantity %}rowvalid{% else %}rowinvalid{% endif %}'{% endif %}>
<td>
{% include "hover_image.html" with image=item.part.image hover=True %}
<a class='hover-icon'a href="{% url 'part-detail' item.part.id %}">{{ item.part.full_name }}</a>
</td>
<td>{{ item.part.description }}</td>
<td>{% decimal item.part.total_stock %}</td>
<td>{% decimal item.quantity %}</td>
<td>{{ item.allocated }}</td>
<td>{% decimal item.part.on_order %}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -1,34 +0,0 @@
{% extends "collapse.html" %}
{% load static %}
{% load inventree_extras %}
{% block collapse_panel_setup %}class='panel part-allocation' id='allocation-panel-{{ item.sub_part.id }}'{% endblock %}
{% block collapse_title %}
{% include "hover_image.html" with image=item.sub_part.image hover=false %}
<div>
{{ item.sub_part.full_name }}
<small><i>{{ item.sub_part.description }}</i></small>
</div>
{% endblock %}
{% block collapse_heading %}
<div class='col-sm-2'>
<b>{% decimal item.sub_part.total_stock %}</b>
</div>
<div class='col-sm-2'>
<b>{% multiply build.quantity item.quantity %}{% if item.overage %} (+ {{ item.overage }}){% endif %}</b>
</div>
<div class='col-sm-2'>
<b><span id='allocation-total-{{ item.sub_part.id }}'>{% part_allocation_count build item.sub_part %}</span></b>
<div class='btn-group' style='float: right;'>
<button class='btn btn-success btn-sm' title='Allocate stock for {{ item.sub_part}}' id='new-item-{{ item.sub_part.id }}' url="{% url 'build-item-create' %}?part={{ item.sub_part.id }}&build={{ build.id }}">Allocate</button>
</div>
</div>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='allocate-table-id-{{ item.sub_part.id }}'>
</table>
{% endblock %}

View File

@@ -1,22 +1,23 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
<b>Build: {{ build.title }}</b> - {{ build.quantity }} x {{ build.part.full_name }}
<br><br>
Automatically allocate stock to this build?
<hr>
<div class='alert alert-block alert-info'>
<b>{% trans "Automatically Allocate Stock" %}</b><br>
{% trans "Stock Items are selected for automatic allocation if there is only a single stock item available." %}<br>
{% trans "The following stock items will be allocated to the build:" %}<br>
</div>
{% if allocations %}
<table class='table table-striped table-condensed'>
<tr>
<th></th>
<th>Part</th>
<th>Quantity</th>
<th>Location</th>
<th>{% trans "Part" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Location" %}</th>
</tr>
{% for item in allocations %}
<tr>
@@ -34,7 +35,9 @@ Automatically allocate stock to this build?
</table>
{% else %}
<i>No stock could be selected for automatic build allocation.</i>
<div class='alert alert-block alert-warning'>
{% trans "No stock items found that can be allocated to this build" %}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,104 +1,114 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block page_title %}
InvenTree | Build - {{ build }}
InvenTree | {% trans "Build" %} - {{ build }}
{% endblock %}
{% block content %}
<div class='row'>
<div class='col-sm-6'>
<div class="media">
<div class="media-left">
<div class='dropzone' id='part-thumb'>
<img class="part-thumb"
{% if build.part.image %}
src="{{ build.part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='media-body'>
<h4>{% trans "Build" %}</h4>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='build-edit' title='Edit Build'>
<span class='glyphicon glyphicon-edit'/>
</button>
{% if build.is_active %}
<button type='button' class='btn btn-default btn-glyph' id='build-complete' title="Complete Build">
<span class='glyphicon glyphicon-send'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
<span class='glyphicon glyphicon-remove'/>
</button>
{% endif %}
{% if build.status == BuildStatus.CANCELLED %}
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
<span class='glyphicon glyphicon-trash'/>
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Build Details" %}</h4>
<table class='table table-striped table-condensed'>
<tr>
<td></td>
<td>{% trans "Build Title" %}</td>
<td>{{ build.title }}</td>
</tr>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>Part</td>
<td><a href="{% url 'part-detail' build.part.id %}">{{ build.part.full_name }}</a></td>
</tr>
<tr>
<td></td>
<td>{% trans "Quantity" %}</td>
<td>{{ build.quantity }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status build.status %}</td>
</tr>
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "BOM Price" %}</td>
<td>
{% if bom_price %}
{{ bom_price }}
{% if build.part.has_complete_bom_pricing == False %}
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
{% endif %}
{% else %}
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% block pre_content %}
{% if build.sales_order %}
<div class='alert alert-block alert-info'>
{% trans "This build is allocated to Sales Order" %} <b><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></b>
</div>
{% endif %}
{% if build.parent %}
<div class='alert alert-block alert-info'>
{% trans "This build is a child of Build" %} <b><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></b>
</div>
{% endif %}
{% endblock %}
{% block thumbnail %}
<img class="part-thumb"
{% if build.part.image %}
src="{{ build.part.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
{% endblock %}
{% block page_data %}
<h3>{% trans "Build" %} {% build_status_label build.status large=True %}</h3>
<hr>
<div class='container-fluid'>
{% block details %}
<h4>{{ build.quantity }} x {{ build.part.full_name }}</h4>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='build-edit' title='Edit Build'>
<span class='fas fa-edit icon-green'/>
</button>
{% if build.is_active %}
<button type='button' class='btn btn-default' id='build-complete' title="Complete Build">
<span class='fas fa-tools'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
<span class='fas fa-times-circle icon-red'/>
</button>
{% endif %}
{% if build.status == BuildStatus.CANCELLED %}
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% endif %}
</div>
</div>
{% endblock %}
</div>
{% block page_details %}
<h4>{% trans "Build Details" %}</h4>
<table class='table table-striped table-condensed'>
<tr>
<td></td>
<td>{% trans "Build Title" %}</td>
<td>{{ build.title }}</td>
</tr>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Part" %}</td>
<td><a href="{% url 'part-detail' build.part.id %}">{{ build.part.full_name }}</a></td>
</tr>
<tr>
<td></td>
<td>{% trans "Quantity" %}</td>
<td>{{ build.quantity }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
{% if build.parent %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Parent Build" %}</td>
<td><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></td>
</tr>
{% endif %}
{% if build.sales_order %}
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "BOM Price" %}</td>
<td>
{% if bom_price %}
{{ bom_price }}
{% if build.part.has_complete_bom_pricing == False %}
<br><span class='warning-msg'><i>{% trans "BOM pricing is incomplete" %}</i></span>
{% endif %}
{% else %}
<span class='warning-msg'><i>{% trans "No pricing information" %}</i></span>
{% endif %}
</td>
</tr>
</table>
{% endblock %}
{% block js_load %}

View File

@@ -1,42 +1,37 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<b>Build: {{ build.title }}</b> - {{ build.quantity }} x {{ build.part.full_name }}
<br>
Are you sure you want to mark this build as complete?
<hr>
{% if taking %}
The following items will be removed from stock:
<table class='table table-striped table-condensed'>
<tr>
<th></th>
<th>Part</th>
<th>Quantity</th>
<th>Location</th>
</tr>
{% for item in taking %}
<tr>
<td>
{% include "hover_image.html" with image=item.stock_item.part.image hover=True %}
</td>
<td>
{{ item.stock_item.part.full_name }}<br>
<i>{{ item.stock_item.part.description }}</i>
</td>
<td>{{ item.quantity }}</td>
<td>{{ item.stock_item.location }}</td>
</tr>
{% endfor %}
</table>
<h4>{% trans "Build" %} - {{ build }}</h4>
{% if build.isFullyAllocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Build order allocation is complete" %}</h4>
</div>
{% else %}
No parts have been allocated to this build.
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning: Build order allocation is not complete" %}</h4>
{% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %}
</div>
{% endif %}
<hr>
The following items will be created:
<div class='alert alert-block alert-success'>
<h4>{% trans "The following actions will be performed:" %}</h4>
<ul>
<li>{% trans "Remove allocated items from stock" %}</li>
<li>{% trans "Add completed items to stock" %}</li>
</ul>
</div>
<div class='panel panel-default'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{{ build.quantity }} x {{ build.part.full_name }}
<div class='panel-heading'>
{% trans "The following items will be created" %}
</div>
<div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %}
{{ build.quantity }} x {{ build.part.full_name }}
</div>
</div>
{% endblock %}

View File

@@ -40,7 +40,7 @@
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% build_status build.status %}</td>
<td>{% build_status_label build.status %}</td>
</tr>
{% if build.batch %}
<tr>

View File

@@ -4,13 +4,13 @@
<li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li>
<li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}">{% trans "Allocated Parts" %}</a>
</li>
<li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}">{% trans "Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
<a href="{% url 'build-output' build.id %}">{% trans "Build Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>
<li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}">{% trans "Assign Parts" %}</a>
</li>
</ul>

View File

@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from build.models import Build, BuildItem
from stock.models import StockItem
from part.models import Part, BomItem
from InvenTree import status_codes as status
from InvenTree.helpers import ExtractSerialNumbers
class BuildTest(TestCase):
"""
Run some tests to ensure that the Build model is working properly.
"""
def setUp(self):
"""
Initialize data to use for these tests.
"""
# Create a base "Part"
self.assembly = Part.objects.create(
name="An assembled part",
description="Why does it matter what my description is?",
assembly=True,
trackable=True,
)
self.sub_part_1 = Part.objects.create(
name="Widget A",
description="A widget",
component=True
)
self.sub_part_2 = Part.objects.create(
name="Widget B",
description="A widget",
component=True
)
# Create BOM item links for the parts
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=10
)
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_2,
quantity=25
)
# Create a "Build" object to make 10x objects
self.build = Build.objects.create(
title="This is a build",
part=self.assembly,
quantity=10
)
# Create some stock items to assign to the build
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
def test_init(self):
# Perform some basic tests before we start the ball rolling
self.assertEqual(StockItem.objects.count(), 3)
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
self.assertFalse(self.build.isFullyAllocated())
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100)
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
self.assertTrue(self.build.can_build)
self.assertFalse(self.build.is_complete)
# Delete some stock and see if the build can still be completed
self.stock_2_1.delete()
self.assertFalse(self.build.can_build)
def test_build_item_clean(self):
# Ensure that dodgy BuildItem objects cannot be created
stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
with self.assertRaises(ValidationError):
b.clean()
# Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999)
with self.assertRaises(ValidationError):
b.clean()
# Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99)
with self.assertRaises(ValidationError):
b.clean()
def test_duplicate_bom_line(self):
# Try to add a duplicate BOM item - it should fail!
with self.assertRaises(IntegrityError):
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_1,
quantity=99
)
def allocate_stock(self, q11, q12, q21):
# Assign stock to this build
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_1,
quantity=q11
)
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_2,
quantity=q12
)
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21
)
with transaction.atomic():
with self.assertRaises(IntegrityError):
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=99
)
self.assertEqual(BuildItem.objects.count(), 3)
def test_partial_allocation(self):
self.allocate_stock(50, 50, 200)
self.assertFalse(self.build.isFullyAllocated())
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2))
self.build.unallocateStock()
self.assertEqual(BuildItem.objects.count(), 0)
def test_auto_allocate(self):
allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1)
self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1)
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2))
def test_cancel(self):
self.allocate_stock(50, 50, 200)
self.build.cancelBuild(None)
self.assertEqual(BuildItem.objects.count(), 0)
def test_complete(self):
self.allocate_stock(50, 50, 250)
self.assertTrue(self.build.isFullyAllocated())
# Generate some serial numbers!
serials = ExtractSerialNumbers("1-10", 10)
self.build.completeBuild(None, serials, None)
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
# the original BuildItem objects should have been deleted!
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
# - Ten for the build output (as the part was serialized)
# - Three for the split items assigned to the build
self.assertEqual(StockItem.objects.count(), 16)
# Stock should have been subtracted from the original items
self.assertEqual(StockItem.objects.get(pk=1).quantity, 950)
self.assertEqual(StockItem.objects.get(pk=2).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 4750)
# New stock items created and assigned to the build
self.assertEqual(StockItem.objects.get(pk=4).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=4).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=4).status, status.StockStatus.ASSIGNED_TO_BUILD)
self.assertEqual(StockItem.objects.get(pk=5).quantity, 50)
self.assertEqual(StockItem.objects.get(pk=5).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=5).status, status.StockStatus.ASSIGNED_TO_BUILD)
self.assertEqual(StockItem.objects.get(pk=6).quantity, 250)
self.assertEqual(StockItem.objects.get(pk=6).build_order, self.build)
self.assertEqual(StockItem.objects.get(pk=6).status, status.StockStatus.ASSIGNED_TO_BUILD)
# And a new stock item created for the build output
self.assertEqual(StockItem.objects.get(pk=7).quantity, 1)
self.assertEqual(StockItem.objects.get(pk=7).serial, 1)
self.assertEqual(StockItem.objects.get(pk=7).build, self.build)

View File

@@ -39,7 +39,7 @@ class BuildTestSimple(TestCase):
self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21)
self.assertEqual(str(b), 'Build 21 x Orphan - A part without a category')
self.assertEqual(str(b), '21 x Orphan')
def test_url(self):
b1 = Build.objects.get(pk=1)

View File

@@ -6,16 +6,6 @@ from django.conf.urls import url, include
from . import views
build_item_detail_urls = [
url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'),
url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'),
]
build_item_urls = [
url(r'^(?P<pk>\d+)/', include(build_item_detail_urls)),
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
]
build_detail_urls = [
url(r'^edit/', views.BuildUpdate.as_view(), name='build-edit'),
url(r'^allocate/', views.BuildAllocate.as_view(), name='build-allocate'),
@@ -33,7 +23,13 @@ build_detail_urls = [
]
build_urls = [
url(r'item/', include(build_item_urls)),
url(r'item/', include([
url(r'^(?P<pk>\d+)/', include([
url('^edit/?', views.BuildItemEdit.as_view(), name='build-item-edit'),
url('^delete/?', views.BuildItemDelete.as_view(), name='build-item-delete'),
])),
url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'),
])),
url(r'new/', views.BuildCreate.as_view(), name='build-create'),

View File

@@ -125,7 +125,7 @@ class BuildAutoAllocate(AjaxUpdateView):
if confirm is False:
form.errors['confirm'] = [_('Confirm stock allocation')]
form.non_field_errors = _('Check the confirmation box at the bottom of the list')
form.non_field_errors = [_('Check the confirmation box at the bottom of the list')]
else:
build.autoAllocate()
valid = True
@@ -159,7 +159,7 @@ class BuildUnallocate(AjaxUpdateView):
if confirm is False:
form.errors['confirm'] = [_('Confirm unallocation of build stock')]
form.non_field_errors = _('Check the confirmation box')
form.non_field_errors = [_('Check the confirmation box')]
else:
build.unallocateStock()
valid = True
@@ -261,13 +261,13 @@ class BuildComplete(AjaxUpdateView):
try:
location = StockLocation.objects.get(id=loc_id)
valid = True
except StockLocation.DoesNotExist:
except (ValueError, StockLocation.DoesNotExist):
form.errors['location'] = [_('Invalid location selected')]
serials = []
if build.part.trackable:
# A build for a trackable part must specify serial numbers
# A build for a trackable part may optionally specify serial numbers.
sn = request.POST.get('serial_numbers', '')
@@ -295,7 +295,9 @@ class BuildComplete(AjaxUpdateView):
valid = False
if valid:
build.completeBuild(location, serials, request.user)
if not build.completeBuild(location, serials, request.user):
form.non_field_errors = [('Build could not be completed')]
valid = False
data = {
'form_valid': valid,
@@ -393,13 +395,15 @@ class BuildCreate(AjaxCreateView):
initials = super(BuildCreate, self).get_initial().copy()
part_id = self.request.GET.get('part', None)
# User has provided a Part ID
initials['part'] = self.request.GET.get('part', None)
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
pass
initials['parent'] = self.request.GET.get('parent', None)
# User has provided a SalesOrder ID
initials['sales_order'] = self.request.GET.get('sales_order', None)
initials['quantity'] = self.request.GET.get('quantity', 1)
return initials
@@ -540,27 +544,64 @@ class BuildItemCreate(AjaxCreateView):
build_id = self.get_param('build')
part_id = self.get_param('part')
# Reference to a Part object
part = None
# Reference to a StockItem object
item = None
# Reference to a Build object
build = None
if part_id:
try:
part = Part.objects.get(pk=part_id)
initials['part'] = part
except Part.DoesNotExist:
part = None
else:
part = None
pass
if build_id:
try:
build = Build.objects.get(pk=build_id)
initials['build'] = build
# Try to work out how many parts to allocate
if part:
unallocated = build.getUnallocatedQuantity(part)
initials['quantity'] = unallocated
except Build.DoesNotExist:
pass
quantity = self.request.GET.get('quantity', None)
if quantity is not None:
quantity = float(quantity)
if quantity is None:
# Work out how many parts remain to be alloacted for the build
if part:
quantity = build.getUnallocatedQuantity(part)
item_id = self.get_param('item')
# If the request specifies a particular StockItem
if item_id:
try:
item = StockItem.objects.get(pk=item_id)
except:
pass
# If a StockItem is not selected, try to auto-select one
if item is None and part is not None:
items = StockItem.objects.filter(part=part)
if items.count() == 1:
item = items.first()
# Finally, if a StockItem is selected, ensure the quantity is not too much
if item is not None:
if quantity is None:
quantity = item.unallocated_quantity()
else:
quantity = min(quantity, item.unallocated_quantity())
if quantity is not None:
initials['quantity'] = quantity
return initials

View File

@@ -31,6 +31,11 @@ class CompanyConfig(AppConfig):
if not os.path.exists(loc):
print("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name))
company.image.render_variations(replace=False)
try:
company.image.render_variations(replace=False)
except FileNotFoundError:
print("Image file missing")
company.image = None
company.save()
except (OperationalError, ProgrammingError):
print("Could not generate Company thumbnails")

View File

@@ -25,7 +25,7 @@ from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.helpers import normalize
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
from common.models import Currency
@@ -185,11 +185,11 @@ class Company(models.Model):
def outstanding_purchase_orders(self):
""" Return purchase orders which are 'outstanding' """
return self.purchase_orders.filter(status__in=OrderStatus.OPEN)
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN)
def pending_purchase_orders(self):
""" Return purchase orders which are PENDING (not yet issued) """
return self.purchase_orders.filter(status=OrderStatus.PENDING)
return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING)
def closed_purchase_orders(self):
""" Return purchase orders which are not 'outstanding'
@@ -199,15 +199,15 @@ class Company(models.Model):
- Returned
"""
return self.purchase_orders.exclude(status__in=OrderStatus.OPEN)
return self.purchase_orders.exclude(status__in=PurchaseOrderStatus.OPEN)
def complete_purchase_orders(self):
return self.purchase_orders.filter(status=OrderStatus.COMPLETE)
return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE)
def failed_purchase_orders(self):
""" Return any purchase orders which were not successful """
return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
class Contact(models.Model):
@@ -384,7 +384,7 @@ class SupplierPart(models.Model):
limited to purchase orders that are open / outstanding.
"""
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=OrderStatus.OPEN)
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatus.OPEN)
def on_order(self):
""" Return the total quantity of items currently on order.

View File

@@ -64,15 +64,11 @@ class CompanySerializer(InvenTreeModelSerializer):
class SupplierPartSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPart object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
pricing = serializers.CharField(source='unit_pricing', read_only=True)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@@ -94,7 +90,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
model = SupplierPart
fields = [
'pk',
'url',
'part',
'part_detail',
'supplier',
@@ -105,7 +100,6 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'description',
'MPN',
'link',
'pricing',
]

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
@@ -7,100 +7,81 @@
InvenTree | {% trans "Company" %} - {{ company.name }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-6">
<div class="media">
<div class='media-left'>
<div class='dropzone' id='company-thumb'>
<img class="part-thumb"
{% if company.image %}
src="{{ company.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='media-body'>
<h4>{{ company.name }}</h4>
<p>{{ company.description }}</p>
<div class='btn-group'>
{% if company.is_supplier %}
<button type='button' class='btn btn-default btn-glyph' id='company-order-2' title='Create purchase order'>
<span class='glyphicon glyphicon-shopping-cart'/>
</button>
{% endif %}
<button type='button' class='btn btn-default btn-glyph' id='company-edit' title='Edit company information'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='company-delete' title='Delete company'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
</div>
</div>
<div class="col-sm-6">
<table class="table">
<col width='25'>
{% if company.website %}
<tr>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
</tr>
{% endif %}
{% if company.address %}
<tr>
<td><span class='fas fa-map-marked-alt'></span></td>
<td>{% trans "Address" %}</td>
<td>{{ company.address }}</td>
</tr>
{% endif %}
{% if company.phone %}
<tr>
<td><span class='fas fa-phone'></span></td>
<td>{% trans "Phone" %}</td>
<td>{{ company.phone }}</td>
</tr>
{% endif %}
{% if company.email %}
<tr>
<td><span class='fas fa-at'></span></td>
<td>{% trans "Email" %}</td>
<td>{{ company.email }}</td>
</tr>
{% endif %}
{% if company.contact %}
<tr>
<td><span class='fas fa-user'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ company.contact }}</td>
</tr>
{% endif %}
</table>
</div>
{% block thumbnail %}
<div class='dropzone' id='company-thumb'>
<img class="part-thumb"
{% if company.image %}
src="{{ company.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
{% endblock %}
{% block page_data %}
<h3>{% trans "Company" %}</h3>
<hr>
<div class='container-fluid'>
{% block details %}
{% endblock %}
<h4>{{ company.name }}</h4>
<p>{{ company.description }}</p>
<div class='btn-group action-buttons'>
{% if company.is_supplier %}
<button type='button' class='btn btn-default' id='company-order-2' title='Create purchase order'>
<span class='fas fa-shopping-cart'/>
</button>
{% endif %}
<button type='button' class='btn btn-default' id='company-edit' title='Edit company information'>
<span class='fas fa-edit icon-green'/>
</button>
<button type='button' class='btn btn-default' id='company-delete' title='Delete company'>
<span class='fas fa-trash-alt icon-red'/>
</button>
</div>
{% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
{% block page_details %}
<h4>{% trans "Company Details" %}</h4>
<table class="table">
<col width='25'>
{% if company.website %}
<tr>
<td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td>
<td><a href="{{ company.website }}">{{ company.website }}</a></td>
</tr>
{% endif %}
{% if company.address %}
<tr>
<td><span class='fas fa-map-marked-alt'></span></td>
<td>{% trans "Address" %}</td>
<td>{{ company.address }}</td>
</tr>
{% endif %}
{% if company.phone %}
<tr>
<td><span class='fas fa-phone'></span></td>
<td>{% trans "Phone" %}</td>
<td>{{ company.phone }}</td>
</tr>
{% endif %}
{% if company.email %}
<tr>
<td><span class='fas fa-at'></span></td>
<td>{% trans "Email" %}</td>
<td>{{ company.email }}</td>
</tr>
{% endif %}
{% if company.contact %}
<tr>
<td><span class='fas fa-user'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ company.contact }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#company-edit').click(function() {
launchModalForm(

View File

@@ -1,8 +1,9 @@
{% extends "company/company_base.html" %}
{% load static %}
{% block details %}
{% load i18n %}
{% block details %}
{% include 'company/tabs.html' with tab='po' %}
<h4>{% trans "Purchase Orders" %}</h4>
@@ -10,8 +11,8 @@
<div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='company-order2' title='Create new purchase order'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-order'>
<button class='btn btn-primary' type='button' id='company-order2' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- Empty div -->
</div>
</div>
@@ -26,7 +27,10 @@
{{ block.super }}
loadPurchaseOrderTable("#purchase-order-table", {
url: "{% url 'api-po-list' %}?supplier={{ company.id }}",
url: "{% url 'api-po-list' %}",
params: {
supplier: {{ company.id }},
}
});

View File

@@ -0,0 +1,41 @@
{% extends "company/company_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'company/tabs.html' with tab='co' %}
<h4>{% trans "Sales Orders" %}</h4>
<hr>
<div id='button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='new-sales-order' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
<div class='filter-list' id='filter-list-salesorder'>
<!-- Empty div -->
</div>
</div>
</div>
<table class='table table-striped table-condensed po-table' id='sales-order-table' data-toolbar='#button-bar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
});
$("#new-sales-order").click(function() {
// TODO - Create a new sales order
});
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
@@ -6,99 +6,86 @@
InvenTree | {% trans "Supplier Part" %}
{% endblock %}
{% block content %}
{% block thumbnail %}
<img class='part-thumb'
{% if part.part.image %}
src='{{ part.part.image.url }}'
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
{% endblock %}
<div class='row'>
<div class='col-sm-6'>
<h3>{% trans "Supplier Part" %}</h3>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
<div class='media-left'>
<img class='part-thumb'
{% if part.part.image %}
src='{{ part.part.image.url }}'
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Supplier Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
{% if part.description %}
<tr>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
{% if part.manufacturer %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>
{% endif %}
</table>
{% block page_data %}
<h3>{% trans "Supplier Part" %}</h3>
<p>{{ part.supplier.name }} - {{ part.SKU }}</p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='Edit supplier part'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='Delete supplier part'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</div>
{% endblock %}
{% block page_details %}
<hr>
<div class='container-fluid'>
{% block details %}
<!-- Particular SupplierPart page goes here ... -->
{% endblock %}
</div>
<h4>{% trans "Supplier Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-suppliers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
{% if part.description %}
<tr>
<td></td>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
{% if part.manufacturer %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}

View File

@@ -18,12 +18,10 @@
</li>
{% endif %}
{% if company.is_customer %}
{% if 0 %}
<li{% if tab == 'co' %} class='active'{% endif %}>
<a href="#">{% trans "Sales Orders" %}</a>
<a href="{% url 'company-detail-sales-orders' company.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ company.sales_orders.count }}</span></a>
</li>
{% endif %}
{% endif %}
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'company-notes' company.id %}">{% trans "Notes" %}{% if company.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>

View File

@@ -15,7 +15,8 @@ company_detail_urls = [
url(r'parts/', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/detail_purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'purchase-orders/?', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'sales-orders/?', views.CompanyDetail.as_view(template_name='company/sales_orders.html'), name='company-detail-sales-orders'),
url(r'notes/?', views.CompanyNotes.as_view(), name='company-notes'),
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),

View File

@@ -13,7 +13,6 @@ from django.urls import reverse
from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.status_codes import OrderStatus
from InvenTree.helpers import str2bool
from common.models import Currency
@@ -137,7 +136,6 @@ class CompanyDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
@@ -244,7 +242,6 @@ class SupplierPartDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx

View File

@@ -42,12 +42,12 @@ cors:
# - https://sub.example.com
# MEDIA_ROOT is the local filesystem location for storing uploaded files
# By default, it is stored in a directory named 'media' local to the InvenTree directory
# By default, it is stored in a directory named 'inventree_media' local to the InvenTree directory
# This should be changed for a production installation
media_root: '../inventree_media'
# STATIC_ROOT is the local filesystem location for storing static files
# By default it is stored in a directory named 'static' local to the InvenTree directory
# By default it is stored in a directory named 'inventree_static' local to the InvenTree directory
static_root: '../inventree_static'
# Optional URL schemes to allow in URL fields

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ from import_export.resources import ModelResource
from import_export.fields import Field
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
class PurchaseOrderAdmin(ImportExportModelAdmin):
@@ -22,6 +24,17 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
)
class SalesOrderAdmin(ImportExportModelAdmin):
list_display = (
'reference',
'customer',
'status',
'description',
'creation_date',
)
class POLineItemResource(ModelResource):
""" Class for managing import / export of POLineItem data """
@@ -40,6 +53,16 @@ class POLineItemResource(ModelResource):
clean_model_instances = True
class SOLineItemResource(ModelResource):
""" Class for managing import / export of SOLineItem data """
class Meta:
model = SalesOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
resource_class = POLineItemResource
@@ -52,5 +75,31 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
)
class SalesOrderLineItemAdmin(ImportExportModelAdmin):
resource_class = SOLineItemResource
list_display = (
'order',
'part',
'quantity',
'reference'
)
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
list_display = (
'line',
'item',
'quantity'
)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(SalesOrder, SalesOrderAdmin)
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)

View File

@@ -8,14 +8,10 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, permissions
from rest_framework import filters
from rest_framework.response import Response
from django.conf import settings
from django.conf.urls import url
from InvenTree.status_codes import OrderStatus
import os
from InvenTree.helpers import str2bool
from part.models import Part
from company.models import SupplierPart
@@ -23,9 +19,12 @@ from company.models import SupplierPart
from .models import PurchaseOrder, PurchaseOrderLineItem
from .serializers import POSerializer, POLineItemSerializer
from .models import SalesOrder, SalesOrderLineItem
from .serializers import SalesOrderSerializer, SOLineItemSerializer
class POList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Order objects
""" API endpoint for accessing a list of PurchaseOrder objects
- GET: Return list of PO objects (with filters)
- POST: Create a new PurchaseOrder object
@@ -34,66 +33,66 @@ class POList(generics.ListCreateAPIView):
queryset = PurchaseOrder.objects.all()
serializer_class = POSerializer
def list(self, request, *args, **kwargs):
def get_serializer(self, *args, **kwargs):
queryset = self.get_queryset().prefetch_related('supplier', 'lines')
try:
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
except AttributeError:
pass
queryset = self.filter_queryset(queryset)
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'supplier',
'lines',
)
queryset = POSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
# Perform basic filtering
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Special filtering for 'status' field
if 'status' in request.GET:
status = request.GET['status']
status = params.get('status', None)
if status is not None:
# First attempt to filter by integer value
try:
status = int(status)
queryset = queryset.filter(status=status)
except ValueError:
try:
value = OrderStatus.value(status)
queryset = queryset.filter(status=value)
except ValueError:
pass
queryset = queryset.filter(status=status)
# Attempt to filter by part
if 'part' in request.GET:
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=request.GET['part'])
part = Part.objects.get(pk=part)
queryset = queryset.filter(id__in=[p.id for p in part.purchase_orders()])
except (Part.DoesNotExist, ValueError):
pass
# Attempt to filter by supplier part
if 'supplier_part' in request.GET:
supplier_part = params.get('supplier_part', None)
if supplier_part is not None:
try:
supplier_part = SupplierPart.objects.get(pk=request.GET['supplier_part'])
supplier_part = SupplierPart.objects.get(pk=supplier_part)
queryset = queryset.filter(id__in=[p.id for p in supplier_part.purchase_orders()])
except (ValueError, SupplierPart.DoesNotExist):
pass
data = queryset.values(
'pk',
'supplier',
'supplier_reference',
'supplier__name',
'supplier__image',
'reference',
'description',
'link',
'status',
'notes',
'creation_date',
)
for item in data:
order = queryset.get(pk=item['pk'])
item['supplier__image'] = os.path.join(settings.MEDIA_URL, item['supplier__image'])
item['status_text'] = OrderStatus.label(item['status'])
item['lines'] = order.lines.count()
return Response(data)
return queryset
permission_classes = [
permissions.IsAuthenticated,
@@ -123,13 +122,38 @@ class PODetail(generics.RetrieveUpdateAPIView):
queryset = PurchaseOrder.objects.all()
serializer_class = POSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
except AttributeError:
pass
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'supplier',
'lines',
)
queryset = POSerializer.annotate_queryset(queryset)
return queryset
permission_classes = [
permissions.IsAuthenticated
]
class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PO Line Item objects
""" API endpoint for accessing a list of POLineItem objects
- GET: Return a list of PO Line Item objects
- POST: Create a new PurchaseOrderLineItem object
@@ -138,6 +162,17 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
permission_classes = [
permissions.IsAuthenticated,
]
@@ -163,10 +198,200 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView):
]
po_api_urls = [
url(r'^order/(?P<pk>\d+)/?$', PODetail.as_view(), name='api-po-detail'),
url(r'^order/?$', POList.as_view(), name='api-po-list'),
class SOList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of SalesOrder objects.
url(r'^line/(?P<pk>\d+)/?$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^line/?$', POLineItemList.as_view(), name='api-po-line-list'),
- GET: Return list of SO objects (with filters)
- POST: Create a new SalesOrder
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False))
except AttributeError:
pass
# Ensure the context is passed through to the serializer
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'customer',
'lines'
)
queryset = SalesOrderSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""
Perform custom filtering operations on the SalesOrder queryset.
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
status = params.get('status', None)
if status is not None:
queryset = queryset.filter(status=status)
# Filter by "Part"
# Only return SalesOrder which have LineItem referencing the part
part = params.get('part', None)
if part is not None:
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(id__in=[so.id for so in part.sales_orders()])
except (Part.DoesNotExist, ValueError):
pass
return queryset
permission_classes = [
permissions.IsAuthenticated
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'customer',
]
ordering_fields = [
'creation_date',
'reference'
]
ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView):
"""
API endpoint for detail view of a SalesOrder object.
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('customer', 'lines')
queryset = SalesOrderSerializer.annotate_queryset(queryset)
return queryset
permission_classes = [permissions.IsAuthenticated]
class SOLineItemList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of SalesOrderLineItem objects.
"""
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
def get_serializer(self, *args, **kwargs):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
except AttributeError:
pass
try:
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
except AttributeError:
pass
try:
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'part',
'part__stock_items',
'allocations',
'allocations__item__location',
'order',
'order__stock_items',
)
return queryset
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend]
filter_fields = [
'order',
'part',
]
class SOLineItemDetail(generics.RetrieveUpdateAPIView):
""" API endpoint for detail view of a SalesOrderLineItem object """
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
permission_classes = [permissions.IsAuthenticated]
order_api_urls = [
# API endpoints for purchase orders
url(r'^po/(?P<pk>\d+)/$', PODetail.as_view(), name='api-po-detail'),
url(r'^po/$', POList.as_view(), name='api-po-list'),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
# API endpoints for sales ordesr
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
url(r'^so/$', SOList.as_view(), name='api-so-list'),
# API endpoints for sales order line items
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'),
]

View File

@@ -15,6 +15,8 @@ from InvenTree.fields import RoundingDecimalFormField
from stock.models import StockLocation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation
class IssuePurchaseOrderForm(HelperForm):
@@ -48,8 +50,30 @@ class CancelPurchaseOrderForm(HelperForm):
fields = [
'confirm',
]
class CancelSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Cancel order'))
class Meta:
model = SalesOrder
fields = [
'confirm',
]
class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=False, help_text=_('Ship order'))
class Meta:
model = SalesOrder
fields = [
'confirm',
]
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, help_text=_('Receive parts to this location'))
@@ -75,6 +99,20 @@ class EditPurchaseOrderForm(HelperForm):
]
class EditSalesOrderForm(HelperForm):
""" Form for editing a SalesOrder object """
class Meta:
model = SalesOrder
fields = [
'reference',
'customer',
'customer_reference',
'description',
'link'
]
class EditPurchaseOrderAttachmentForm(HelperForm):
""" Form for editing a PurchaseOrderAttachment object """
@@ -87,6 +125,18 @@ class EditPurchaseOrderAttachmentForm(HelperForm):
]
class EditSalesOrderAttachmentForm(HelperForm):
""" Form for editing a SalesOrderAttachment object """
class Meta:
model = SalesOrderAttachment
fields = [
'order',
'attachment',
'comment'
]
class EditPurchaseOrderLineItemForm(HelperForm):
""" Form for editing a PurchaseOrderLineItem object """
@@ -101,3 +151,32 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'reference',
'notes',
]
class EditSalesOrderLineItemForm(HelperForm):
""" Form for editing a SalesOrderLineItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderLineItem
fields = [
'order',
'part',
'quantity',
'reference',
'notes'
]
class EditSalesOrderAllocationForm(HelperForm):
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity']

View File

@@ -0,0 +1,76 @@
# Generated by Django 3.0.5 on 2020-04-20 09:40
import InvenTree.fields
import InvenTree.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import markdownx.models
class Migration(migrations.Migration):
dependencies = [
('company', '0021_remove_supplierpart_manufacturer_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('order', '0019_purchaseorder_supplier_reference'),
]
operations = [
migrations.CreateModel(
name='SalesOrder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reference', models.CharField(help_text='Order reference', max_length=64, unique=True)),
('description', models.CharField(help_text='Order description', max_length=250)),
('link', models.URLField(blank=True, help_text='Link to external page')),
('creation_date', models.DateField(blank=True, null=True)),
('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Order status')),
('issue_date', models.DateField(blank=True, null=True)),
('complete_date', models.DateField(blank=True, null=True)),
('notes', markdownx.models.MarkdownxField(blank=True, help_text='Order notes')),
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(help_text='Customer', limit_choices_to={True, 'is_supplier'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier',
field=models.ForeignKey(help_text='Supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='company.Company'),
),
migrations.AlterField(
model_name='purchaseorder',
name='supplier_reference',
field=models.CharField(blank=True, help_text='Supplier order reference code', max_length=64),
),
migrations.CreateModel(
name='SalesOrderLineItem',
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)])),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100)),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500)),
('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.SalesOrder')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SalesOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(help_text='Select file to attach', upload_to=InvenTree.models.rename_attachment)),
('comment', models.CharField(help_text='File comment', max_length=100)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.SalesOrder')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 10:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0021_remove_supplierpart_manufacturer_name'),
('order', '0020_auto_20200420_0940'),
]
operations = [
migrations.AlterField(
model_name='salesorder',
name='customer',
field=models.ForeignKey(help_text='Customer', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.Company'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 22:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0021_auto_20200420_1010'),
]
operations = [
migrations.AddField(
model_name='salesorderlineitem',
name='part',
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='part.Part'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-20 23:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0022_salesorderlineitem_part'),
]
operations = [
migrations.AlterField(
model_name='salesorderlineitem',
name='part',
field=models.ForeignKey(help_text='Part', limit_choices_to={'salable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_order_line_items', to='part.Part'),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.0.5 on 2020-04-22 02:09
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0030_auto_20200422_0015'),
('order', '0023_auto_20200420_2309'),
]
operations = [
migrations.CreateModel(
name='SalesOrderAllocation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)])),
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem')),
('line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.SalesOrderLineItem')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-22 02:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0024_salesorderallocation'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderallocation',
unique_together={('line', 'item')},
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-22 02:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0025_auto_20200422_0222'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.OneToOneField(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocation', to='stock.StockItem'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-22 02:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0026_auto_20200422_0224'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 3.0.5 on 2020-04-23 09:56
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0031_auto_20200422_0209'),
('order', '0027_auto_20200422_0236'),
]
operations = [
migrations.AlterField(
model_name='purchaseorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Placed'), (30, 'Complete'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status'),
),
migrations.AlterField(
model_name='salesorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
migrations.AlterField(
model_name='salesorderallocation',
name='quantity',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Enter stock allocation quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.0.5 on 2020-04-23 10:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('order', '0028_auto_20200423_0956'),
]
operations = [
migrations.RenameField(
model_name='salesorder',
old_name='complete_date',
new_name='shipment_date',
),
migrations.RemoveField(
model_name='salesorder',
name='issue_date',
),
migrations.AddField(
model_name='salesorder',
name='shipped_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 05:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0033_auto_20200426_0539'),
('order', '0029_auto_20200423_1042'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'customer': None, 'part__salable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-04-26 06:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0034_auto_20200426_0602'),
('order', '0030_auto_20200426_0551'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='item',
field=models.ForeignKey(help_text='Select stock item to allocate', limit_choices_to={'belongs_to': None, 'build_order': None, 'part__salable': True, 'sales_order': None}, on_delete=django.db.models.deletion.CASCADE, related_name='sales_order_allocations', to='stock.StockItem'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-04-27 00:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0035_auto_20200406_0045'),
('order', '0031_auto_20200426_0612'),
]
operations = [
migrations.AlterUniqueTogether(
name='salesorderlineitem',
unique_together={('order', 'part')},
),
]

View File

@@ -5,7 +5,8 @@ Order model definitions
# -*- coding: utf-8 -*-
from django.db import models, transaction
from django.db.models import F
from django.db.models import F, Sum
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
@@ -16,13 +17,15 @@ from markdownx.models import MarkdownxField
import os
from datetime import datetime
from decimal import Decimal
from stock.models import StockItem
from part import models as PartModels
from stock import models as stock_models
from company.models import Company, SupplierPart
from InvenTree.fields import RoundingDecimalField
from InvenTree.helpers import decimal2string
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
from InvenTree.models import InvenTreeAttachment
@@ -73,65 +76,42 @@ class Order(models.Model):
creation_date = models.DateField(blank=True, null=True)
status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(),
help_text='Order status')
created_by = models.ForeignKey(User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
)
issue_date = models.DateField(blank=True, null=True)
complete_date = models.DateField(blank=True, null=True)
notes = MarkdownxField(blank=True, help_text=_('Order notes'))
def place_order(self):
""" Marks the order as PLACED. Order must be currently PENDING. """
if self.status == OrderStatus.PENDING:
self.status = OrderStatus.PLACED
self.issue_date = datetime.now().date()
self.save()
def complete_order(self):
""" Marks the order as COMPLETE. Order must be currently PLACED. """
if self.status == OrderStatus.PLACED:
self.status = OrderStatus.COMPLETE
self.complete_date = datetime.now().date()
self.save()
def cancel_order(self):
""" Marks the order as CANCELLED. """
if self.status in [OrderStatus.PLACED, OrderStatus.PENDING]:
self.status = OrderStatus.CANCELLED
self.save()
class PurchaseOrder(Order):
""" A PurchaseOrder represents goods shipped inwards from an external supplier.
Attributes:
supplier: Reference to the company supplying the goods in the order
supplier_reference: Optional field for supplier order reference code
received_by: User that received the goods
"""
ORDER_PREFIX = "PO"
def __str__(self):
return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name)
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
help_text='Purchase order status')
supplier = models.ForeignKey(
Company, on_delete=models.CASCADE,
limit_choices_to={
'is_supplier': True,
},
related_name='purchase_orders',
help_text=_('Company')
help_text=_('Supplier')
)
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference"))
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code"))
received_by = models.ForeignKey(
User,
@@ -140,6 +120,10 @@ class PurchaseOrder(Order):
related_name='+'
)
issue_date = models.DateField(blank=True, null=True)
complete_date = models.DateField(blank=True, null=True)
def get_absolute_url(self):
return reverse('po-detail', kwargs={'pk': self.id})
@@ -188,6 +172,29 @@ class PurchaseOrder(Order):
line.save()
def place_order(self):
""" Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """
if self.status == PurchaseOrderStatus.PENDING:
self.status = PurchaseOrderStatus.PLACED
self.issue_date = datetime.now().date()
self.save()
def complete_order(self):
""" Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """
if self.status == PurchaseOrderStatus.PLACED:
self.status = PurchaseOrderStatus.COMPLETE
self.complete_date = datetime.now().date()
self.save()
def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """
if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]:
self.status = PurchaseOrderStatus.CANCELLED
self.save()
def pending_line_items(self):
""" Return a list of pending line items for this order.
Any line item where 'received' < 'quantity' will be returned.
@@ -206,7 +213,7 @@ class PurchaseOrder(Order):
""" Receive a line item (or partial line item) against this PO
"""
if not self.status == OrderStatus.PLACED:
if not self.status == PurchaseOrderStatus.PLACED:
raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
try:
@@ -218,7 +225,7 @@ class PurchaseOrder(Order):
# Create a new stock item
if line.part:
stock = StockItem(
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
@@ -244,6 +251,115 @@ class PurchaseOrder(Order):
self.complete_order() # This will save the model
class SalesOrder(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
"""
def __str__(self):
return "SO {ref} - {company}".format(ref=self.reference, company=self.customer.name)
def get_absolute_url(self):
return reverse('so-detail', kwargs={'pk': self.id})
customer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
help_text=_("Customer"),
)
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
help_text='Purchase order status')
customer_reference = models.CharField(max_length=64, blank=True, help_text=_("Customer order reference code"))
shipment_date = models.DateField(blank=True, null=True)
shipped_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
related_name='+'
)
@property
def is_pending(self):
return self.status == SalesOrderStatus.PENDING
def is_fully_allocated(self):
""" Return True if all line items are fully allocated """
for line in self.lines.all():
if not line.is_fully_allocated():
return False
return True
def is_over_allocated(self):
""" Return true if any lines in the order are over-allocated """
for line in self.lines.all():
if line.is_over_allocated():
return True
return False
@transaction.atomic
def ship_order(self, user):
""" Mark this order as 'shipped' """
# The order can only be 'shipped' if the current status is PENDING
if not self.status == SalesOrderStatus.PENDING:
raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")})
# Complete the allocation for each allocated StockItem
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.complete_allocation(user)
# Remove the allocation from the database once it has been 'fulfilled'
if allocation.item.sales_order == self:
allocation.delete()
else:
raise ValidationError("Could not complete order - allocation item not fulfilled")
# Ensure the order status is marked as "Shipped"
self.status = SalesOrderStatus.SHIPPED
self.shipment_date = datetime.now().date()
self.shipped_by = user
self.save()
return True
@transaction.atomic
def cancel_order(self):
"""
Cancel this order (only if it is "pending")
- Mark the order as 'cancelled'
- Delete any StockItems which have been allocated
"""
if not self.status == SalesOrderStatus.PENDING:
return False
self.status = SalesOrderStatus.CANCELLED
self.save()
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.delete()
return True
class PurchaseOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a PurchaseOrder object
@@ -255,6 +371,17 @@ class PurchaseOrderAttachment(InvenTreeAttachment):
order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments")
class SalesOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a SalesOrder object
"""
def getSubdir(self):
return os.path.join("so_files", str(self.order.id))
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments')
class OrderLineItem(models.Model):
""" Abstract model for an order line item
@@ -300,6 +427,10 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_('Purchase Order')
)
def get_base_part(self):
""" Return the base-part for the line item """
return self.part.part
# TODO - Function callback for when the SupplierPart is deleted?
part = models.ForeignKey(
@@ -315,3 +446,171 @@ class PurchaseOrderLineItem(OrderLineItem):
""" Calculate the number of items remaining to be received """
r = self.quantity - self.received
return max(r, 0)
class SalesOrderLineItem(OrderLineItem):
"""
Model for a single LineItem in a SalesOrder
Attributes:
order: Link to the SalesOrder that this line item belongs to
part: Link to a Part object (may be null)
"""
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', help_text=_('Sales Order'))
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, help_text=_('Part'), limit_choices_to={'salable': True})
class Meta:
unique_together = [
('order', 'part'),
]
def fulfilled_quantity(self):
"""
Return the total stock quantity fulfilled against this line item.
"""
query = self.order.stock_items.filter(part=self.part).aggregate(fulfilled=Coalesce(Sum('quantity'), Decimal(0)))
return query['fulfilled']
def allocated_quantity(self):
""" Return the total stock quantity allocated to this LineItem.
This is a summation of the quantity of each attached StockItem
"""
query = self.allocations.aggregate(allocated=Coalesce(Sum('quantity'), Decimal(0)))
return query['allocated']
def is_fully_allocated(self):
""" Return True if this line item is fully allocated """
if self.order.status == SalesOrderStatus.SHIPPED:
return self.fulfilled_quantity() >= self.quantity
return self.allocated_quantity() >= self.quantity
def is_over_allocated(self):
""" Return True if this line item is over allocated """
return self.allocated_quantity() > self.quantity
class SalesOrderAllocation(models.Model):
"""
This model is used to 'allocate' stock items to a SalesOrder.
Items that are "allocated" to a SalesOrder are not yet "attached" to the order,
but they will be once the order is fulfilled.
Attributes:
line: SalesOrderLineItem reference
item: StockItem reference
quantity: Quantity to take from the StockItem
"""
class Meta:
unique_together = [
# Cannot allocate any given StockItem to the same line more than once
('line', 'item'),
]
def clean(self):
"""
Validate the SalesOrderAllocation object:
- Cannot allocate stock to a line item without a part reference
- The referenced part must match the part associated with the line item
- Allocated quantity cannot exceed the quantity of the stock item
- Allocation quantity must be "1" if the StockItem is serialized
- Allocation quantity cannot be zero
"""
super().clean()
errors = {}
try:
if not self.line.part == self.item.part:
errors['item'] = _('Cannot allocate stock item to a line with a different part')
except PartModels.Part.DoesNotExist:
errors['line'] = _('Cannot allocate stock to a line without a part')
if self.quantity > self.item.quantity:
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
# TODO: The logic here needs improving. Do we need to subtract our own amount, or something?
if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity:
errors['quantity'] = _('StockItem is over-allocated')
if self.quantity <= 0:
errors['quantity'] = _('Allocation quantity must be greater than zero')
if self.item.serial and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, related_name='allocations')
item = models.ForeignKey(
'stock.StockItem',
on_delete=models.CASCADE,
related_name='sales_order_allocations',
limit_choices_to={
'part__salable': True,
'belongs_to': None,
'sales_order': None,
'build_order': None,
},
help_text=_('Select stock item to allocate')
)
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, help_text=_('Enter stock allocation quantity'))
def get_serial(self):
return self.item.serial
def get_location(self):
return self.item.location.id if self.item.location else None
def get_location_path(self):
if self.item.location:
return self.item.location.pathstring
else:
return ""
def complete_allocation(self, user):
"""
Complete this allocation (called when the parent SalesOrder is marked as "shipped"):
- Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity)
- Mark the StockItem as belonging to the Customer (this will remove it from stock)
"""
order = self.line.order
item = self.item
# If the allocated quantity is less than the amount available,
# then split the stock item into two lots
if item.quantity > self.quantity:
# Grab a copy of the new stock item (which will keep track of its "parent")
item = item.splitStock(self.quantity, None, user)
# Update our own reference to the new item
self.item = item
self.save()
# Assign the StockItem to the SalesOrder customer
item.sales_order = order
# Clear the location
item.location = None
item.status = StockStatus.SHIPPED
item.save()

View File

@@ -5,25 +5,64 @@ JSON serializers for the Order API
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import serializers
from django.db.models import Count
from InvenTree.serializers import InvenTreeModelSerializer
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
class POSerializer(InvenTreeModelSerializer):
""" Serializes an Order object """
""" Serializer for a PurchaseOrder object """
def __init__(self, *args, **kwargs):
supplier_detail = kwargs.pop('supplier_detail', False)
super().__init__(*args, **kwargs)
if supplier_detail is not True:
self.fields.pop('supplier_detail')
@staticmethod
def annotate_queryset(queryset):
"""
Add extra information to the queryset
"""
return queryset.annotate(
line_items=Count('lines'),
)
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)
class Meta:
model = PurchaseOrder
fields = [
'pk',
'supplier',
'supplier_reference',
'reference',
'issue_date',
'complete_date',
'creation_date',
'description',
'line_items',
'link',
'reference',
'supplier',
'supplier_detail',
'supplier_reference',
'status',
'status_text',
'notes',
]
@@ -35,6 +74,22 @@ class POSerializer(InvenTreeModelSerializer):
class POLineItemSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField()
received = serializers.FloatField()
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
class Meta:
model = PurchaseOrderLineItem
@@ -45,5 +100,134 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'notes',
'order',
'part',
'part_detail',
'supplier_part_detail',
'received',
]
class SalesOrderSerializer(InvenTreeModelSerializer):
"""
Serializers for the SalesOrder object
"""
def __init__(self, *args, **kwargs):
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):
"""
Add extra information to the queryset
"""
return queryset.annotate(
line_items=Count('lines'),
)
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)
class Meta:
model = SalesOrder
fields = [
'pk',
'shipment_date',
'creation_date',
'description',
'line_items',
'link',
'reference',
'customer',
'customer_detail',
'customer_reference',
'status',
'status_text',
'shipment_date',
'notes',
]
read_only_fields = [
'reference',
'status'
]
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""
Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
location_path = serializers.CharField(source='get_location_path')
location_id = serializers.IntegerField(source='get_location')
serial = serializers.CharField(source='get_serial')
quantity = serializers.FloatField()
class Meta:
model = SalesOrderAllocation
fields = [
'pk',
'line',
'serial',
'quantity',
'location_id',
'location_path',
'item',
]
class SOLineItemSerializer(InvenTreeModelSerializer):
""" Serializer for a SalesOrderLineItem object """
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if order_detail is not True:
self.fields.pop('order_detail')
if allocations is not True:
self.fields.pop('allocations')
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
class Meta:
model = SalesOrderLineItem
fields = [
'pk',
'allocated',
'allocations',
'quantity',
'fulfilled',
'reference',
'notes',
'order',
'order_detail',
'part',
'part_detail',
]

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "two_column.html" %}
{% load i18n %}
{% load static %}
@@ -6,129 +6,114 @@
{% load status_codes %}
{% block page_title %}
InvenTree | {{ order }}
InvenTree | {% trans "Purchase Order" %}
{% endblock %}
{% block content %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.supplier.image %}
src="{{ order.supplier.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock %}
<div class='row'>
<div class='col-sm-6'>
<div class='media'>
<div class='media-left'>
<img class='part-thumb'
{% if order.supplier.image %}
src="{{ order.supplier.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
</div>
<div class='media-body'>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
<p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-order' title='Edit order information'>
<span class='glyphicon glyphicon-edit'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='export-order' title='Export order to file'>
<span class='glyphicon glyphicon-download-alt'></span>
</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default btn-glyph' id='place-order' title='Place order'>
<span class='glyphicon glyphicon-send'></span>
</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='receive-order' title='Receive items'>
<span class='glyphicon glyphicon-check'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='complete-order' title='Mark order as complete'>
<span class='glyphicon glyphicon-ok'></span>
</button>
{% endif %}
{% if order.status == OrderStatus.PENDING or order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='cancel-order' title='Cancel order'>
<span class='glyphicon glyphicon-remove'></span>
</button>
{% endif %}
</div>
</div>
</p>
</div>
{% block page_data %}
<h3>{% trans "Purchase Order" %} {% purchase_order_status_label order.status large=True %}</h3>
<hr>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
<p>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
<span class='fas fa-edit icon-green'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='Export order to file'>
<span class='fas fa-file-download'></span>
</button>
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default' id='place-order' title='Place order'>
<span class='fas fa-paper-plane icon-blue'></span>
</button>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='receive-order' title='Receive items'>
<span class='fas fa-clipboard-check'></span>
</button>
<button type='button' class='btn btn-default' id='complete-order' title='Mark order as complete'>
<span class='fas fa-check-circle'></span>
</button>
{% endif %}
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='cancel-order' title='Cancel order'>
<span class='fas fa-times-circle icon-red'></span>
</button>
{% endif %}
</div>
</div>
<div class='col-sm-6'>
<h4>{% trans "Purchase Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% order_status order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</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></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ 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>{{ order.issue_date }}</td>
</tr>
{% endif %}
{% if order.status == OrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
</div>
</div>
<hr>
<div class='container-fluid'>
{% block details %}
<!-- Specific order details to go here -->
</p>
{% endblock %}
</div>
{% block page_details %}
<h4>{% trans "Purchase Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% purchase_order_status_label order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</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></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ 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>{{ order.issue_date }}</td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
$("#place-order").click(function() {
launchModalForm("{% url 'po-issue' order.id %}",
{

View File

@@ -1,7 +1,9 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Cancelling this order means that the order will no longer be editable.
{% trans "Cancelling this order means that the order will no longer be editable." %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block details %}
{% include 'order/tabs.html' with tab='notes' %}
{% include 'order/po_tabs.html' with tab='notes' %}
{% if editing %}
<h4>{% trans "Order Notes" %}</h4>

View File

@@ -6,7 +6,7 @@
{% block details %}
{% include 'order/tabs.html' with tab='attachments' %}
{% include 'order/po_tabs.html' with tab='attachments' %}
<h4>{% trans "Purchase Order Attachments" %}

View File

@@ -7,78 +7,19 @@
{% block details %}
{% include 'order/tabs.html' with tab='details' %}
{% include 'order/po_tabs.html' with tab='details' %}
<hr>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == OrderStatus.PENDING %}
{% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>{% trans "Add Line Item" %}</button>
{% endif %}
</div>
<h4>{% trans "Order Items" %}</h4>
<h4>{% trans "Purchase Order Items" %}</h4>
<table class='table table-striped table-condensed' id='po-lines-table' data-toolbar='#order-toolbar-buttons'>
<thead>
<tr>
<th data-sortable='true'>{% trans "Line" %}</th>
<th data-sortable='true'>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th data-sortable='true'>{% trans "Order Code" %}</th>
<th data-sortable='true'>{% trans "Reference" %}</th>
<th data-sortable='true'>{% trans "Quantity" %}</th>
{% if not order.status == OrderStatus.PENDING %}
<th data-sortable='true'>{% trans "Received" %}</th>
{% endif %}
<th>{% trans "Note" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for line in order.lines.all %}
<tr{% if order.status == OrderStatus.PLACED %} class={% if line.received < line.quantity %}'rowinvalid'{% else %}'rowvalid'{% endif %}{% endif %}>
<td>
{{ forloop.counter }}
</td>
{% if line.part %}
<td>
{% include "hover_image.html" with image=line.part.part.image hover=True %}
<a href="{% url 'part-detail' line.part.part.id %}">{{ line.part.part.full_name }}</a>
</td>
<td>{{ line.part.part.description }}</td>
<td><a href="{% url 'supplier-part-detail' line.part.id %}">{{ line.part.SKU }}</a></td>
{% else %}
<td colspan='3'><strong>Warning: Part has been deleted.</strong></td>
{% endif %}
<td>{{ line.reference }}</td>
<td>{% decimal line.quantity %}</td>
{% if not order.status == OrderStatus.PENDING %}
<td>{% decimal line.received %}</td>
{% endif %}
<td>
{{ line.notes }}
</td>
<td>
<div class='btn-group'>
{% if order.status == OrderStatus.PENDING %}
<button class='btn btn-default btn-glyph' line='{{ line.id }}' id='edit-line-item-{{ line.id }} title='Edit line item' onclick='editPurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-edit' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-edit'></span>
</button>
<button class='btn btn-default btn-glyph' line='{{ line.id }}' id='remove-line-item-{{ line.id }' title='Remove line item' type='button' onclick='removePurchaseOrderLineItem()'>
<span url="{% url 'po-line-item-delete' line.id %}" line='{{ line.id }}' class='glyphicon glyphicon-remove'></span>
</button>
{% endif %}
{% if order.status == OrderStatus.PLACED and line.received < line.quantity %}
<button class='btn btn-default btn-glyph line-receive' pk='{{ line.pk }}' title='Receive item(s)'>
<span class='glyphicon glyphicon-check'></span>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<table class='table table-striped table-condensed' id='po-table' data-toolbar='#order-toolbar-buttons'>
</table>
{% endblock %}
@@ -87,27 +28,6 @@
{{ block.super }}
$("#po-lines-table").on('click', ".line-receive", function() {
var button = $(this);
console.log('clicked! ' + button.attr('pk'));
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true,
data: {
line: button.attr('pk')
},
secondary: [
{
field: 'location',
label: 'New Location',
title: 'Create new stock location',
url: "{% url 'stock-location-create' %}",
},
]
});
});
$("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
@@ -115,8 +35,8 @@ $("#receive-order").click(function() {
secondary: [
{
field: 'location',
label: 'New Location',
title: 'Create new stock location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
@@ -133,7 +53,7 @@ $("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
});
{% if order.status == OrderStatus.PENDING %}
{% if order.status == PurchaseOrderStatus.PENDING %}
$('#new-po-line').click(function() {
launchModalForm("{% url 'po-line-item-create' %}",
{
@@ -144,8 +64,8 @@ $('#new-po-line').click(function() {
secondary: [
{
field: 'part',
label: 'New Supplier Part',
title: 'Create new supplier part',
label: '{% trans "New Supplier Part" %}',
title: '{% trans "Create new supplier part" %}',
url: "{% url 'supplier-part-create' %}",
data: {
supplier: {{ order.supplier.id }},
@@ -157,7 +77,153 @@ $('#new-po-line').click(function() {
});
{% endif %}
$("#po-lines-table").inventreeTable({
function reloadTable() {
$("#po-table").bootstrapTable("refresh");
}
function setupCallbacks() {
// Setup callbacks for the line buttons
var table = $("#po-table");
{% if order.status == PurchaseOrderStatus.PENDING %}
table.find(".button-line-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/line/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-line-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/line/${pk}/delete/`, {
success: reloadTable,
});
});
{% endif %}
table.find(".button-line-receive").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'po-receive' order.id %}", {
success: reloadTable,
data: {
line: pk,
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: "{% url 'stock-location-create' %}",
},
]
});
});
}
$("#po-table").inventreeTable({
onPostBody: setupCallbacks,
formatNoMatches: function() { return "{% trans 'No line items found' %}"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
},
url: "{% url 'api-po-line-list' %}",
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'part',
sortable: true,
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
},
},
{
sortable: true,
field: 'part_detail.description',
title: '{% trans "Description" %}',
},
{
sortable: true,
field: 'supplier_part_detail.SKU',
title: '{% trans "Order Code" %}',
formatter: function(value, row, index, field) {
return renderLink(value, `/supplier-part/${row.part}/`);
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}'
},
{
sortable: true,
field: 'received',
title: '{% trans "Received" %}',
formatter: function(value, row, index, field) {
return makeProgressBar(row.received, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.received) / rowA.quantity;
var progressB = parseFloat(rowB.received) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
field: 'buttons',
title: '',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group'>`;
var pk = row.pk;
{% if order.status == PurchaseOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED %}
if (row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
{% endif %}
html += `</div>`;
return html;
},
}
]
});

View File

@@ -4,18 +4,18 @@
{% load i18n %}
{% block page_title %}
InvenTree | Purchase Orders
InvenTree | {% trans "Purchase Orders" %}
{% endblock %}
{% block content %}
<h3>Purchase Orders</h3>
<h3>{% trans "Purchase Orders" %}</h3>
<hr>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='po-create' title='Create new purchase order'>New Purchase Order</button>
<div class='filter-list' id='filter-list-order'>
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>

View File

@@ -0,0 +1,133 @@
{% extends "two_column.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% block page_title %}
InvenTree | {% trans "Sales Order" %}
{% endblock %}
{% block pre_content %}
{% if order.status == SalesOrderStatus.PENDING and not order.is_fully_allocated %}
<div class='alert alert-block alert-danger'>
{% trans "This SalesOrder has not been fully allocated" %}
</div>
{% endif %}
{% endblock %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.customer.image %}
src="{{ order.customer.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock %}
{% block page_data %}
<h3>{% trans "Sales Order" %} {% sales_order_status_label order.status large=True %}</h3>
<hr>
<h4>{{ order }}</h4>
<p>{{ order.description }}</p>
<div class='btn-row'>
<div class='btn-group action-buttons'>
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
<span class='fas fa-edit icon-green'></span>
</button>
<button type='button' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
<span class='fas fa-clipboard-list'></span>
</button>
{% if order.status == SalesOrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
<span class='fas fa-paper-plane icon-blue'></span>
</button>
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
<span class='fas fa-times-circle icon-red'></span>
</button>
{% endif %}
</div>
</div>
{% endblock %}
{% block page_details %}
<h4>{% trans "Sales Order Details" %}</h4>
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% sales_order_status_label order.status %}</td>
</tr>
<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></td>
</tr>
{% if order.customer_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Customer Reference" %}</td>
<td>{{ order.customer_reference }}</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></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge'>{{ order.created_by }}</span></td>
</tr>
{% if order.shipment_date %}
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Shipped" %}</td>
<td>{{ order.shipment_date }}<span class='badge'>{{ order.shipped_by }}</span></td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#edit-order").click(function() {
launchModalForm("{% url 'so-edit' order.id %}", {
reload: true,
});
});
$("#cancel-order").click(function() {
launchModalForm("{% url 'so-cancel' order.id %}", {
reload: true,
});
});
$("#ship-order").click(function() {
launchModalForm("{% url 'so-ship' order.id %}", {
reload: true,
});
});
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
<h4>{% trans "Warning" %}</h4>
{% trans "Cancelling this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@@ -0,0 +1,361 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load status_codes %}
{% load i18n %}
{% load static %}
{% block details %}
{% include "order/so_tabs.html" with tab='details' %}
<hr>
<h4>{% trans "Sales Order Items" %}</h4>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default' id='new-so-line'>{% trans "Add Line Item" %}</button>
</div>
<table class='table table-striped table-condensed' id='so-lines-table' data-toolbar='#order-toolbar-buttons'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
function reloadTable() {
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() {
launchModalForm("{% url 'so-line-item-create' %}", {
success: reloadTable,
data: {
order: {{ order.id }},
},
secondary: [
]
});
});
{% if order.status == SalesOrderStatus.PENDING %}
function showAllocationSubTable(index, row, element) {
// Construct a table showing stock items which have been allocated against this line item
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
element.html(html);
var lineItem = row;
var table = $(`#allocation-table-${row.pk}`);
table.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'allocated',
title: 'Quantity',
formatter: function(value, row, index, field) {
var text = '';
if (row.serial != null && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.item}/`);
},
},
{
field: 'location_id',
title: 'Location',
formatter: function(value, row, index, field) {
return renderLink(row.location_path, `/stock/location/${row.location_id}/`);
},
},
{
field: 'buttons',
title: 'Actions',
formatter: function(value, row, index, field) {
var html = "<div class='btn-group float-right' role='group'>";
var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += "</div>";
return html;
},
},
],
});
table.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-allocation-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, {
success: reloadTable,
});
});
}
{% endif %}
function showFulfilledSubTable(index, row, element) {
// Construct a table showing stock items which have been fulfilled against this line item
var id = `fulfilled-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='${id}'></table></div>`;
element.html(html);
var lineItem = row;
$(`#${id}`).bootstrapTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
part: row.part,
sales_order: {{ order.id }},
},
showHeader: false,
columns: [
{
field: 'pk',
visible: false,
},
{
field: 'stock',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.pk}/`);
},
}
],
});
}
$("#so-lines-table").inventreeTable({
formatNoMatches: function() { return "No matching line items"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
allocations: true,
},
uniqueId: 'pk',
url: "{% url 'api-so-line-list' %}",
onPostBody: setupCallbacks,
{% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %}
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
{% if order.status == SalesOrderStatus.PENDING %}
return row.allocated > 0;
{% else %}
return row.fulfilled > 0;
{% endif %}
},
{% if order.status == SalesOrderStatus.PENDING %}
detailFormatter: showAllocationSubTable,
{% else %}
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
sortable: true,
field: 'part',
title: 'Part',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
}
},
{
sortable: true,
field: 'reference',
title: 'Reference'
},
{
sortable: true,
field: 'quantity',
title: 'Quantity',
},
{
sortable: true,
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}
title: '{% trans "Allocated" %}',
{% else %}
title: '{% trans "Fulfilled" %}',
{% endif %}
formatter: function(value, row, index, field) {
{% if order.status == SalesOrderStatus.PENDING %}
var quantity = row.allocated;
{% else %}
var quantity = row.fulfilled;
{% endif %}
return makeProgressBar(quantity, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
{% if order.status == SalesOrderStatus.PENDING %}
var A = rowA.allocated;
var B = rowB.allocated;
{% else %}
var A = rowA.fulfilled;
var B = rowB.fulfilled;
{% endif %}
if (A == 0 && B == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(A) / rowA.quantity;
var progressB = parseFloat(B) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: 'Notes',
},
{% if order.status == SalesOrderStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (row.part) {
var part = row.part_detail;
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
}
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}');
}
html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;
return html;
}
},
{% endif %}
],
});
function setupCallbacks() {
var table = $("#so-lines-table");
// Set up callbacks for the row buttons
table.find(".button-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/line/${pk}/delete/`, {
reload: true,
});
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/new/`, {
success: reloadTable,
data: {
line: pk,
},
});
});
table.find(".button-build").click(function() {
var pk = $(this).attr('pk');
// Extract the row data from the table!
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
var quantity = 1;
if (row.allocated < row.quantity) {
quantity = row.quantity - row.allocated;
}
launchModalForm(`/build/new/`, {
follow: true,
data: {
part: pk,
sales_order: {{ order.id }},
quantity: quantity,
},
});
});
table.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [pk],
},
});
});
}
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "order/sales_order_base.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% load markdownify %}
{% block page_title %}
InvenTree | {% trans "Sales Order" %}
{% endblock %}
{% block details %}
{% include "order/so_tabs.html" with tab='notes' %}
{% if editing %}
<h4>{% trans "Order Notes" %}</h4>
<hr>
<form method='POST'>
{% csrf_token %}
{{ form }}
<hr>
<input type='submit' value='{% trans "Save" %}'/>
</form>
{{ form.media }}
{% else %}
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Order Notes" %}</h4>
</div>
<div class='col-sm-6'>
<button title='{% trans "Edit notes" %}' class='btn btn-default btn-glyph float-right' id='edit-notes'><span class='glyphicon glyphicon-edit'></span></button>
</div>
</div>
<hr>
<div class='panel panel-default'>
<div class='panel-content'>
{{ order.notes | markdownify }}
</div>
</div>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if editing %}
{% else %}
$("#edit-notes").click(function() {
location.href = "{% url 'so-notes' order.id %}?edit=1";
});
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% if not order.is_fully_allocated %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning" %}</h4>
{% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %}
<br>
{% trans "Ensure that the order allocation is correct before shipping the order." %}
</div>
{% endif %}
{% if order.is_over_allocated %}
<div class='alert alert-block alert-warning'>
{% trans "Some line items in this order have been over-allocated" %}
<br>
{% trans "Ensure that this is correct before shipping the order." %}
</div>
{% endif %}
<div class='alert alert-block alert-info'>
<b>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</b>
<br>
{% trans "Shipping this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block page_title %}
InvenTree | {% trans "Sales Orders" %}
{% endblock %}
{% block content %}
<h3>{% trans "Sales Orders" %}</h3>
<hr>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>
</div>
</div>
<table class='table table-striped table-condensed po-table' data-toolbar='#table-buttons' id='sales-order-table'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
});
$("#so-create").click(function() {
launchModalForm("{% url 'so-create' %}",
{
follow: true,
}
);
});
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}:
<br>
<b>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</b>
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block details %}
{% include 'order/so_tabs.html' with tab='attachments' %}
<h4>{% trans "Sales Order Attachments" %}
<hr>
<div id='attachment-buttons'>
<div class='btn-group'>
<button type='button' class='btn btn-success' id='new-attachment'>{% trans "Add Attachment" %}</button>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#attachment-buttons' id='attachment-table'>
<thead>
<tr>
<th data-field='file' data-searchable='true'>{% trans "File" %}</th>
<th data-field='comment' data-searchable='true'>{% trans "Comment" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for attachment in order.attachments.all %}
<tr>
<td><a href='/media/{{ attachment.attachment }}'>{{ attachment.basename }}</a></td>
<td>{{ attachment.comment }}</td>
<td>
<div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' url="{% url 'so-attachment-edit' attachment.id %}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'>
<span class='glyphicon glyphicon-edit'/>
</button>
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' url="{% url 'so-attachment-delete' attachment.id %}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'>
<span class='glyphicon glyphicon-trash'/>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#new-attachment").click(function() {
launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
{
reload: true,
}
);
});
$("#attachment-table").on('click', '.attachment-edit-button', function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$("#attachment-table").on('click', '.attachment-delete-button', function() {
var button = $(this);
launchModalForm(button.attr('url'), {
reload: true,
});
});
$("#attachment-table").inventreeTable({
});
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "order/sales_order_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block details %}
{% include 'order/so_tabs.html' with tab='builds' %}
<h4>{% trans "Build Orders" %}</h4>
<hr>
<table class='table table-striped table-condensed' id='builds-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildTable($("#builds-table"), {
url: "{% url 'api-build-list' %}",
params: {
sales_order: {{ order.id }},
part_detail: true,
},
});
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% trans "Are you sure you wish to delete this line item?" %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% load i18n %}
<ul class='nav nav-tabs'>
<li{% ifequal tab 'details' %} class='active'{% endifequal %}>
<a href="{% url 'so-detail' order.id %}">{% trans "Order Items" %}</a>
</li>
<li{% if tab == 'builds' %} class='active'{% endif %}>
<a href="{% url 'so-builds' order.id %}">
{% trans "Build Orders" %}
{% if order.builds.count > 0 %}
<span class='badge'>{{ order.builds.count }}</span>
{% endif %}
</a>
</li>
<li{% if tab == 'attachments' %} class='active'{% endif %}>
<a href="{% url 'so-attachments' order.id %}">{% trans "Attachments" %}
{% if order.attachments.count > 0 %}
<span class='badge'>{{ order.attachments.count }}</span>
{% endif %}
</a>
</li>
<li{% ifequal tab 'notes' %} class='active'{% endifequal %}>
<a href="{% url 'so-notes' order.id %}">{% trans "Notes" %}{% if order.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>
</ul>

View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from company.models import Company
from stock.models import StockItem
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from part.models import Part
from InvenTree import status_codes as status
class SalesOrderTest(TestCase):
"""
Run tests to ensure that the SalesOrder model is working correctly.
"""
def setUp(self):
# Create a Company to ship the goods to
self.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True)
# Create a Part to ship
self.part = Part.objects.create(name='Spanner', salable=True, description='A spanner that I sell')
# Create some stock!
StockItem.objects.create(part=self.part, quantity=100)
StockItem.objects.create(part=self.part, quantity=200)
# Create a SalesOrder to ship against
self.order = SalesOrder.objects.create(
customer=self.customer,
reference='1234',
customer_reference='ABC 55555'
)
# Create a line item
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
def test_empty_order(self):
self.assertEqual(self.line.quantity, 50)
self.assertEqual(self.line.allocated_quantity(), 0)
self.assertEqual(self.line.fulfilled_quantity(), 0)
self.assertFalse(self.line.is_fully_allocated())
self.assertFalse(self.line.is_over_allocated())
self.assertTrue(self.order.is_pending)
self.assertFalse(self.order.is_fully_allocated())
def test_add_duplicate_line_item(self):
# Adding a duplicate line item to a SalesOrder must throw an error
with self.assertRaises(IntegrityError):
SalesOrderLineItem.objects.create(order=self.order, part=self.part)
def allocate_stock(self, full=True):
# Allocate stock to the order
SalesOrderAllocation.objects.create(
line=self.line,
item=StockItem.objects.get(pk=1),
quantity=25)
SalesOrderAllocation.objects.create(
line=self.line,
item=StockItem.objects.get(pk=2),
quantity=25 if full else 20
)
def test_allocate_partial(self):
# Partially allocate stock
self.allocate_stock(False)
self.assertFalse(self.order.is_fully_allocated())
self.assertFalse(self.line.is_fully_allocated())
self.assertEqual(self.line.allocated_quantity(), 45)
self.assertEqual(self.line.fulfilled_quantity(), 0)
def test_allocate_full(self):
# Fully allocate stock
self.allocate_stock(True)
self.assertTrue(self.order.is_fully_allocated())
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.allocated_quantity(), 50)
def test_order_cancel(self):
# Allocate line items then cancel the order
self.allocate_stock(True)
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.assertEqual(self.order.status, status.SalesOrderStatus.PENDING)
self.order.cancel_order()
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
# Now try to ship it - should fail
with self.assertRaises(ValidationError):
self.order.ship_order(None)
def test_ship_order(self):
# Allocate line items, then ship the order
# Assert some stuff before we run the test
# Initially there are two stock items
self.assertEqual(StockItem.objects.count(), 2)
# Take 25 units from each StockItem
self.allocate_stock(True)
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.order.ship_order(None)
# There should now be 4 stock items
self.assertEqual(StockItem.objects.count(), 4)
self.assertEqual(StockItem.objects.get(pk=1).quantity, 75)
self.assertEqual(StockItem.objects.get(pk=2).quantity, 175)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
self.assertEqual(StockItem.objects.get(pk=1).sales_order, None)
self.assertEqual(StockItem.objects.get(pk=2).sales_order, None)
self.assertEqual(StockItem.objects.get(pk=3).sales_order, self.order)
self.assertEqual(StockItem.objects.get(pk=4).sales_order, self.order)
# And no allocations
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
self.assertTrue(self.order.is_fully_allocated())
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_quantity(), 0)

View File

@@ -7,7 +7,7 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem
@@ -53,7 +53,7 @@ class POTests(OrderViewTestCase):
response = self.client.get(reverse('po-detail', args=(1,)))
self.assertEqual(response.status_code, 200)
keys = response.context.keys()
self.assertIn('OrderStatus', keys)
self.assertIn('PurchaseOrderStatus', keys)
def test_po_create(self):
""" Launch forms to create new PurchaseOrder"""
@@ -91,7 +91,7 @@ class POTests(OrderViewTestCase):
url = reverse('po-issue', args=(1,))
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
# Test without confirmation
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
@@ -109,7 +109,7 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PLACED)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
def test_line_item_create(self):
""" Test the form for adding a new LineItem to a PurchaseOrder """
@@ -117,7 +117,7 @@ class POTests(OrderViewTestCase):
# Record the number of line items in the PurchaseOrder
po = PurchaseOrder.objects.get(pk=1)
n = po.lines.count()
self.assertEqual(po.status, OrderStatus.PENDING)
self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
url = reverse('po-line-item-create')
@@ -181,7 +181,7 @@ class TestPOReceive(OrderViewTestCase):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
self.po.status = OrderStatus.PLACED
self.po.status = PurchaseOrderStatus.PLACED
self.po.save()
self.url = reverse('po-receive', args=(1,))

View File

@@ -6,7 +6,7 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
from stock.models import StockLocation
from company.models import SupplierPart
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
class OrderTest(TestCase):
@@ -31,11 +31,11 @@ class OrderTest(TestCase):
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
self.assertEqual(str(order), 'PO 1')
self.assertEqual(str(order), 'PO 1 - ACME')
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1)")
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)")
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """
@@ -57,7 +57,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 3)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
@@ -104,14 +104,14 @@ class OrderTest(TestCase):
self.assertEqual(len(order.pending_line_items()), 3)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
with self.assertRaises(django_exceptions.ValidationError):
order.receive_line_item(line, loc, 50, user=None)
order.place_order()
self.assertEqual(order.status, OrderStatus.PLACED)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
order.receive_line_item(line, loc, 50, user=None)
@@ -134,9 +134,9 @@ class OrderTest(TestCase):
order.receive_line_item(line, loc, 500, user=None)
self.assertEqual(part.on_order, 800)
self.assertEqual(order.status, OrderStatus.PLACED)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
for line in order.pending_line_items():
order.receive_line_item(line, loc, line.quantity, user=None)
self.assertEqual(order.status, OrderStatus.COMPLETE)
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)

View File

@@ -9,21 +9,15 @@ from django.conf.urls import url, include
from . import views
purchase_order_attachment_urls = [
url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
]
purchase_order_detail_urls = [
url(r'^cancel/?', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='po-edit'),
url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/?', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/?', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^edit/', views.PurchaseOrderEdit.as_view(), name='po-edit'),
url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^export/?', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),
@@ -31,19 +25,6 @@ purchase_order_detail_urls = [
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='po-detail'),
]
po_line_item_detail_urls = [
url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
]
po_line_urls = [
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
url(r'^(?P<pk>\d+)/', include(po_line_item_detail_urls)),
]
purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
@@ -53,14 +34,72 @@ purchase_order_urls = [
# Display detail view for a single purchase order
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
url(r'^line/', include(po_line_urls)),
url(r'^line/', include([
url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
])),
])),
url(r'^attachments/', include(purchase_order_attachment_urls)),
url(r'^attachments/', include([
url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
])),
# Display complete list of purchase orders
url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'),
]
sales_order_detail_urls = [
url(r'^edit/', views.SalesOrderEdit.as_view(), name='so-edit'),
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
url(r'^builds/', views.SalesOrderDetail.as_view(template_name='order/so_builds.html'), name='so-builds'),
url(r'^attachments/', views.SalesOrderDetail.as_view(template_name='order/so_attachments.html'), name='so-attachments'),
url(r'^notes/', views.SalesOrderNotes.as_view(), name='so-notes'),
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
]
sales_order_urls = [
url(r'^new/', views.SalesOrderCreate.as_view(), name='so-create'),
url(r'^line/', include([
url(r'^new/', views.SOLineItemCreate.as_view(), name='so-line-item-create'),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/', views.SOLineItemEdit.as_view(), name='so-line-item-edit'),
url(r'^delete/', views.SOLineItemDelete.as_view(), name='so-line-item-delete'),
])),
])),
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),
])),
])),
url(r'^attachments/', include([
url(r'^new/', views.SalesOrderAttachmentCreate.as_view(), name='so-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.SalesOrderAttachmentEdit.as_view(), name='so-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.SalesOrderAttachmentDelete.as_view(), name='so-attachment-delete'),
])),
# Display detail view for a single SalesOrder
url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),
# Display list of all sales orders
url(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'),
]
order_urls = [
url(r'^purchase-order/', include(purchase_order_urls)),
url(r'^sales-order/', include(sales_order_urls)),
]

View File

@@ -16,6 +16,8 @@ import logging
from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
from .models import SalesOrderAllocation
from .admin import POLineItemResource
from build.models import Build
from company.models import Company, SupplierPart
@@ -27,7 +29,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.status_codes import OrderStatus
from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger(__name__)
@@ -50,11 +52,16 @@ class PurchaseOrderIndex(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class SalesOrderIndex(ListView):
model = SalesOrder
template_name = 'order/sales_orders.html'
context_object_name = 'orders'
class PurchaseOrderDetail(DetailView):
""" Detail view for a PurchaseOrder object """
@@ -65,11 +72,17 @@ class PurchaseOrderDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['OrderStatus'] = OrderStatus
return ctx
class SalesOrderDetail(DetailView):
""" Detail view for a SalesOrder object """
context_object_name = 'order'
queryset = SalesOrder.objects.all().prefetch_related('lines')
template_name = 'order/sales_order_detail.html'
class PurchaseOrderAttachmentCreate(AjaxCreateView):
"""
View for creating a new PurchaseOrderAtt
@@ -113,6 +126,34 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
return form
class SalesOrderAttachmentCreate(AjaxCreateView):
""" View for creating a new SalesOrderAttachment """
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _('Add Sales Order Attachment')
def get_data(self):
return {
'success': _('Added attachment')
}
def get_initial(self):
initials = super().get_initial().copy()
initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None))
return initials
def get_form(self):
""" Hide the 'order' field """
form = super().get_form()
form.fields['order'].widget = HiddenInput()
return form
class PurchaseOrderAttachmentEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderAttachment object """
@@ -134,12 +175,46 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
return form
class SalesOrderAttachmentEdit(AjaxUpdateView):
""" View for editing a SalesOrderAttachment object """
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _("Edit Attachment")
def get_data(self):
return {
'success': _('Attachment updated')
}
def get_form(self):
form = super().get_form()
form.fields['order'].widget = HiddenInput()
return form
class PurchaseOrderAttachmentDelete(AjaxDeleteView):
""" View for deleting a PurchaseOrderAttachment """
model = PurchaseOrderAttachment
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/po_delete.html"
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
def get_data(self):
return {
"danger": _("Deleted attachment")
}
class SalesOrderAttachmentDelete(AjaxDeleteView):
""" View for deleting a SalesOrderAttachment """
model = SalesOrderAttachment
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
def get_data(self):
@@ -165,7 +240,28 @@ class PurchaseOrderNotes(UpdateView):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
ctx['editing'] = str2bool(self.request.GET.get('edit', False))
return ctx
class SalesOrderNotes(UpdateView):
""" View for editing the 'notes' field of a SalesORder """
context_object_name = 'order'
template_name = 'order/sales_order_notes.html'
model = SalesOrder
fields = ['notes']
def get_success_url(self):
return reverse('so-notes', kwargs={'pk': self.get_object().pk})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', False))
return ctx
@@ -180,7 +276,7 @@ class PurchaseOrderCreate(AjaxCreateView):
def get_initial(self):
initials = super().get_initial().copy()
initials['status'] = OrderStatus.PENDING
initials['status'] = PurchaseOrderStatus.PENDING
supplier_id = self.request.GET.get('supplier', None)
@@ -200,6 +296,35 @@ class PurchaseOrderCreate(AjaxCreateView):
self.object.save()
class SalesOrderCreate(AjaxCreateView):
""" View for creating a new SalesOrder object """
model = SalesOrder
ajax_form_title = _("Create Sales Order")
form_class = order_forms.EditSalesOrderForm
def get_initial(self):
initials = super().get_initial().copy()
initials['status'] = PurchaseOrderStatus.PENDING
customer_id = self.request.GET.get('customer', None)
if customer_id is not None:
try:
customer = Company.objects.get(id=customer_id)
initials['customer'] = customer
except (Company.DoesNotExist, ValueError):
pass
return initials
def post_save(self, **kwargs):
# Record the user who created this sales order
self.object.created_by = self.request.user
self.object.save()
class PurchaseOrderEdit(AjaxUpdateView):
""" View for editing a PurchaseOrder using a modal form """
@@ -214,12 +339,28 @@ class PurchaseOrderEdit(AjaxUpdateView):
order = self.get_object()
# Prevent user from editing supplier if there are already lines in the order
if order.lines.count() > 0 or not order.status == OrderStatus.PENDING:
if order.lines.count() > 0 or not order.status == PurchaseOrderStatus.PENDING:
form.fields['supplier'].widget = HiddenInput()
return form
class SalesOrderEdit(AjaxUpdateView):
""" View for editing a SalesOrder """
model = SalesOrder
ajax_form_title = _('Edit Sales Order')
form_class = order_forms.EditSalesOrderForm
def get_form(self):
form = super().get_form()
# Prevent user from editing customer
form.fields['customer'].widget = HiddenInput()
return form
class PurchaseOrderCancel(AjaxUpdateView):
""" View for cancelling a purchase order """
@@ -253,6 +394,40 @@ class PurchaseOrderCancel(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class SalesOrderCancel(AjaxUpdateView):
""" View for cancelling a sales order """
model = SalesOrder
ajax_form_title = _("Cancel sales order")
ajax_template_name = "order/sales_order_cancel.html"
form_class = order_forms.CancelSalesOrderForm
def post(self, request, *args, **kwargs):
order = self.get_object()
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm:
form.errors['confirm'] = [_('Confirm order cancellation')]
else:
valid = True
if valid:
if not order.cancel_order():
form.non_field_errors = [_('Could not cancel order')]
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class PurchaseOrderIssue(AjaxUpdateView):
""" View for changing a purchase order from 'PENDING' to 'ISSUED' """
@@ -310,7 +485,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
if confirm:
po = self.get_object()
po.status = OrderStatus.COMPLETE
po.status = PurchaseOrderStatus.COMPLETE
po.save()
data = {
@@ -322,6 +497,48 @@ class PurchaseOrderComplete(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class SalesOrderShip(AjaxUpdateView):
""" View for 'shipping' a SalesOrder """
form_class = order_forms.ShipSalesOrderForm
model = SalesOrder
context_object_name = 'order'
ajax_template_name = 'order/sales_order_ship.html'
ajax_form_title = _('Ship Order')
def post(self, request, *args, **kwargs):
self.request = request
order = self.get_object()
self.object = order
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm:
form.errors['confirm'] = [_('Confirm order shipment')]
else:
valid = True
if valid:
if not order.ship_order(request.user):
form.non_field_errors = [_('Could not ship order')]
valid = False
data = {
'form_valid': valid,
}
context = self.get_context_data()
context['order'] = order
return self.renderJsonResponse(request, form, data, context)
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order
@@ -879,7 +1096,7 @@ class POLineItemCreate(AjaxCreateView):
# Limit the available to orders to ones that are PENDING
query = form.fields['order'].queryset
query = query.filter(status=OrderStatus.PENDING)
query = query.filter(status=PurchaseOrderStatus.PENDING)
form.fields['order'].queryset = query
order_id = form['order'].value()
@@ -924,12 +1141,80 @@ class POLineItemCreate(AjaxCreateView):
order = PurchaseOrder.objects.get(id=order_id)
initials['order'] = order
except PurchaseOrder.DoesNotExist:
except (PurchaseOrder.DoesNotExist, ValueError):
pass
return initials
class SOLineItemCreate(AjaxCreateView):
""" Ajax view for creating a new SalesOrderLineItem object """
model = SalesOrderLineItem
context_order_name = 'line'
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Add Line Item')
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
# If the order is specified, hide the widget
order_id = form['order'].value()
if SalesOrder.objects.filter(id=order_id).exists():
form.fields['order'].widget = HiddenInput()
return form
def get_initial(self):
"""
Extract initial data for this line item:
Options:
order: The SalesOrder object
part: The Part object
"""
initials = super().get_initial().copy()
order_id = self.request.GET.get('order', None)
part_id = self.request.GET.get('part', None)
if order_id:
try:
order = SalesOrder.objects.get(id=order_id)
initials['order'] = order
except (SalesOrder.DoesNotExist, ValueError):
pass
if part_id:
try:
part = Part.objects.get(id=part_id)
if part.salable:
initials['part'] = part
except (Part.DoesNotExist, ValueError):
pass
return initials
class SOLineItemEdit(AjaxUpdateView):
""" View for editing a SalesOrderLineItem """
model = SalesOrderLineItem
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Edit Line Item')
def get_form(self):
form = super().get_form()
form.fields.pop('order')
form.fields.pop('part')
return form
class POLineItemEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderLineItem object in a modal form.
"""
@@ -960,3 +1245,109 @@ class POLineItemDelete(AjaxDeleteView):
return {
'danger': _('Deleted line item'),
}
class SOLineItemDelete(AjaxDeleteView):
model = SalesOrderLineItem
ajax_form_title = _("Delete Line Item")
ajax_template_name = "order/so_lineitem_delete.html"
def get_data(self):
return {
'danger': _('Deleted line item'),
}
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):
initials = super().get_initial().copy()
line_id = self.request.GET.get('line', None)
if line_id is not None:
line = SalesOrderLineItem.objects.get(pk=line_id)
initials['line'] = line
# Search for matching stock items, pre-fill if there is only one
items = StockItem.objects.filter(part=line.part)
quantity = line.quantity - line.allocated_quantity()
if quantity < 0:
quantity = 0
if items.count() == 1:
item = items.first()
initials['item'] = item
# Reduce the quantity IF there is not enough stock
qmax = item.quantity - item.allocation_count()
if qmax < quantity:
quantity = qmax
initials['quantity'] = quantity
return initials
def get_form(self):
form = super().get_form()
line_id = form['line'].value()
# If a line item has been specified, reduce the queryset for the stockitem accordingly
try:
line = SalesOrderLineItem.objects.get(pk=line_id)
queryset = form.fields['item'].queryset
# Ensure the part reference matches
queryset = queryset.filter(part=line.part)
# Exclude StockItem which are already allocated to this order
allocated = [allocation.item.pk for allocation in line.allocations.all()]
queryset = queryset.exclude(pk__in=allocated)
form.fields['item'].queryset = queryset
# Hide the 'line' field
form.fields['line'].widget = HiddenInput()
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
return form
class SalesOrderAllocationEdit(AjaxUpdateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Edit Allocation Quantity')
def get_form(self):
form = super().get_form()
# Prevent the user from editing particular fields
form.fields.pop('item')
form.fields.pop('line')
return form
class SalesOrderAllocationDelete(AjaxDeleteView):
model = SalesOrderAllocation
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"

View File

@@ -6,10 +6,8 @@ Provides a JSON API for the Part app
from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from django.conf import settings
from django.db.models import Q, F, Sum, Count
from django.db.models.functions import Coalesce
from django.db.models import Q, F, Count
from rest_framework import status
from rest_framework.response import Response
@@ -19,15 +17,11 @@ from rest_framework import generics, permissions
from django.conf.urls import url, include
from django.urls import reverse
import os
from decimal import Decimal
from .models import Part, PartCategory, BomItem, PartStar
from .models import PartParameter, PartParameterTemplate
from . import serializers as part_serializers
from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull
@@ -125,6 +119,8 @@ class PartThumbs(generics.ListAPIView):
# Get all Parts which have an associated image
queryset = Part.objects.all().exclude(image='')
# TODO - We should return the thumbnails here, not the full image!
# Return the most popular parts first
data = queryset.values(
'image',
@@ -138,11 +134,41 @@ class PartDetail(generics.RetrieveUpdateAPIView):
queryset = Part.objects.all()
serializer_class = part_serializers.PartSerializer
starred_parts = None
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
def get_serializer(self, *args, **kwargs):
try:
cat_detail = str2bool(self.request.query_params.get('category_detail', False))
except AttributeError:
cat_detail = None
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
kwargs['category_detail'] = cat_detail
# Pass a list of "starred" parts fo the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
return self.serializer_class(*args, **kwargs)
class PartList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Part objects
@@ -166,6 +192,31 @@ class PartList(generics.ListCreateAPIView):
serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all()
starred_parts = None
def get_serializer(self, *args, **kwargs):
try:
cat_detail = str2bool(self.request.query_params.get('category_detail', False))
except AttributeError:
cat_detail = None
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
kwargs['category_detail'] = cat_detail
# Pass a list of "starred" parts fo the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
return self.serializer_class(*args, **kwargs)
def create(self, request, *args, **kwargs):
""" Override the default 'create' behaviour:
We wish to save the user who created this part!
@@ -184,129 +235,20 @@ class PartList(generics.ListCreateAPIView):
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def list(self, request, *args, **kwargs):
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""
Instead of using the DRF serialiser to LIST,
we serialize the objects manually.
This turns out to be significantly faster.
Perform custom filtering of the queryset
"""
queryset = self.filter_queryset(self.get_queryset())
# Filters for annotations
# "in_stock" count should only sum stock items which are "in stock"
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
# "on_order" items should only sum orders which are currently outstanding
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN)
# "building" should only reference builds which are active
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
# Set of fields we wish to serialize
data = queryset.values(
'pk',
'category',
'image',
'name',
'IPN',
'revision',
'description',
'keywords',
'is_template',
'link',
'units',
'minimum_stock',
'trackable',
'assembly',
'component',
'salable',
'active',
).annotate(
# Quantity of items which are "in stock"
in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter), Decimal(0)),
on_order=Coalesce(Sum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter), Decimal(0)),
building=Coalesce(Sum('builds__quantity', filter=build_filter), Decimal(0)),
)
# If we are filtering by 'has_stock' status
has_stock = self.request.query_params.get('has_stock', None)
if has_stock is not None:
has_stock = str2bool(has_stock)
if has_stock:
# Filter items which have a non-null 'in_stock' quantity above zero
data = data.filter(in_stock__gt=0)
else:
# Filter items which a null or zero 'in_stock' quantity
data = data.filter(Q(in_stock__lte=0))
# If we are filtering by 'low_stock' status
low_stock = self.request.query_params.get('low_stock', None)
if low_stock is not None:
low_stock = str2bool(low_stock)
if low_stock:
# Ignore any parts which do not have a specified 'minimum_stock' level
data = data.exclude(minimum_stock=0)
# Filter items which have an 'in_stock' level lower than 'minimum_stock'
data = data.filter(Q(in_stock__lt=F('minimum_stock')))
else:
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
data = data.filter(Q(in_stock__gte=F('minimum_stock')))
# Get a list of the parts that this user has starred
starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
# Reduce the number of lookups we need to do for the part categories
categories = {}
for item in data:
if item['image']:
# Is this part 'starred' for the current user?
item['starred'] = item['pk'] in starred_parts
img = item['image']
# Use the 'thumbnail' image here instead of the full-size image
# Note: The full-size image is used when requesting the /api/part/<x>/ endpoint
if img:
fn, ext = os.path.splitext(img)
thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext)
thumb = os.path.join(settings.MEDIA_URL, thumb)
else:
thumb = ''
item['thumbnail'] = thumb
del item['image']
cat_id = item['category']
if cat_id:
if cat_id not in categories:
categories[cat_id] = PartCategory.objects.get(pk=cat_id).pathstring
item['category__name'] = categories[cat_id]
else:
item['category__name'] = None
return Response(data)
def get_queryset(self):
"""
Implement custom filtering for the Part list API
"""
# Start with all objects
parts_list = Part.objects.all()
# Perform basic filtering
queryset = super().filter_queryset(queryset)
# Filter by 'starred' parts?
starred = str2bool(self.request.query_params.get('starred', None))
@@ -315,12 +257,13 @@ class PartList(generics.ListCreateAPIView):
starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()]
if starred:
parts_list = parts_list.filter(pk__in=starred_parts)
queryset = queryset.filter(pk__in=starred_parts)
else:
parts_list = parts_list.exclude(pk__in=starred_parts)
queryset = queryset.exclude(pk__in=starred_parts)
# Cascade?
cascade = str2bool(self.request.query_params.get('cascade', None))
# Does the user wish to filter by category?
cat_id = self.request.query_params.get('category', None)
@@ -334,7 +277,7 @@ class PartList(generics.ListCreateAPIView):
# A 'null' category is the top-level category
if cascade is False:
# Do not cascade, only list parts in the top-level category
parts_list = parts_list.filter(category=None)
queryset = queryset.filter(category=None)
else:
try:
@@ -342,17 +285,43 @@ class PartList(generics.ListCreateAPIView):
# If '?cascade=true' then include parts which exist in sub-categories
if cascade:
parts_list = parts_list.filter(category__in=category.getUniqueChildren())
queryset = queryset.filter(category__in=category.getUniqueChildren())
# Just return parts directly in the requested category
else:
parts_list = parts_list.filter(category=cat_id)
queryset = queryset.filter(category=cat_id)
except (ValueError, PartCategory.DoesNotExist):
pass
# Ensure that related models are pre-loaded to reduce DB trips
parts_list = self.get_serializer_class().setup_eager_loading(parts_list)
# Annotate calculated data to the queryset
# (This will be used for further filtering)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return parts_list
# Filter by whether the part has stock
has_stock = self.request.query_params.get("has_stock", None)
if has_stock is not None:
has_stock = str2bool(has_stock)
if has_stock:
queryset = queryset.filter(Q(in_stock__gt=0))
else:
queryset = queryset.filter(Q(in_stock__lte=0))
# If we are filtering by 'low_stock' status
low_stock = self.request.query_params.get('low_stock', None)
if low_stock is not None:
low_stock = str2bool(low_stock)
if low_stock:
# Ignore any parts which do not have a specified 'minimum_stock' level
queryset = queryset.exclude(minimum_stock=0)
# Filter items which have an 'in_stock' level lower than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
else:
# Filter items which have an 'in_stock' level higher than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
return queryset
permission_classes = [
permissions.IsAuthenticated,
@@ -379,6 +348,7 @@ class PartList(generics.ListCreateAPIView):
'name',
]
# Default ordering
ordering = 'name'
search_fields = [
@@ -507,7 +477,9 @@ class BomList(generics.ListCreateAPIView):
kwargs['part_detail'] = part_detail
kwargs['sub_part_detail'] = sub_part_detail
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_queryset(self):

View File

@@ -30,6 +30,11 @@ class PartConfig(AppConfig):
if not os.path.exists(loc):
print("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
part.image.render_variations(replace=False)
try:
part.image.render_variations(replace=False)
except FileNotFoundError:
print("Image file missing")
part.image = None
part.save()
except (OperationalError, ProgrammingError):
print("Could not generate Part thumbnails")

Some files were not shown because too many files have changed in this diff Show More