inventree-component-shortfall 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inventree_component_shortfall-0.1.0/LICENSE +21 -0
- inventree_component_shortfall-0.1.0/MANIFEST.in +2 -0
- inventree_component_shortfall-0.1.0/PKG-INFO +41 -0
- inventree_component_shortfall-0.1.0/README.md +25 -0
- inventree_component_shortfall-0.1.0/component_shortfall/__init__.py +3 -0
- inventree_component_shortfall-0.1.0/component_shortfall/core.py +183 -0
- inventree_component_shortfall-0.1.0/component_shortfall/serializers.py +46 -0
- inventree_component_shortfall-0.1.0/component_shortfall/shortfall.py +324 -0
- inventree_component_shortfall-0.1.0/component_shortfall/static/.vite/manifest.json +8 -0
- inventree_component_shortfall-0.1.0/component_shortfall/static/Dashboard.js +85 -0
- inventree_component_shortfall-0.1.0/component_shortfall/static/Dashboard.js.map +1 -0
- inventree_component_shortfall-0.1.0/component_shortfall/templates/component_shortfall/shortfall_email.html +35 -0
- inventree_component_shortfall-0.1.0/component_shortfall/views.py +58 -0
- inventree_component_shortfall-0.1.0/inventree_component_shortfall.egg-info/PKG-INFO +41 -0
- inventree_component_shortfall-0.1.0/inventree_component_shortfall.egg-info/SOURCES.txt +19 -0
- inventree_component_shortfall-0.1.0/inventree_component_shortfall.egg-info/dependency_links.txt +1 -0
- inventree_component_shortfall-0.1.0/inventree_component_shortfall.egg-info/entry_points.txt +2 -0
- inventree_component_shortfall-0.1.0/inventree_component_shortfall.egg-info/top_level.txt +3 -0
- inventree_component_shortfall-0.1.0/pyproject.toml +53 -0
- inventree_component_shortfall-0.1.0/setup.cfg +19 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oliver Walters <oliver.henry.walters@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inventree-component-shortfall
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate component shortfall reports
|
|
5
|
+
Author-email: Oliver Walters <oliver.henry.walters@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SchrodingersGat/inventree-shortfall-report
|
|
8
|
+
Keywords: inventree,plugin
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Framework :: InvenTree
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# ComponentShortfall
|
|
18
|
+
|
|
19
|
+
Generate component shortfall reports
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### InvenTree Plugin Manager
|
|
24
|
+
|
|
25
|
+
... todo ...
|
|
26
|
+
|
|
27
|
+
### Command Line
|
|
28
|
+
|
|
29
|
+
To install manually via the command line, run the following command:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install inventree-component-shortfall
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
... todo ...
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
... todo ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ComponentShortfall
|
|
2
|
+
|
|
3
|
+
Generate component shortfall reports
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### InvenTree Plugin Manager
|
|
8
|
+
|
|
9
|
+
... todo ...
|
|
10
|
+
|
|
11
|
+
### Command Line
|
|
12
|
+
|
|
13
|
+
To install manually via the command line, run the following command:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install inventree-component-shortfall
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
... todo ...
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
... todo ...
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Generate component shortfall reports"""
|
|
2
|
+
|
|
3
|
+
from plugin import InvenTreePlugin
|
|
4
|
+
|
|
5
|
+
from plugin.mixins import ScheduleMixin, SettingsMixin, UrlsMixin, UserInterfaceMixin
|
|
6
|
+
|
|
7
|
+
from . import PLUGIN_VERSION
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ComponentShortfall(
|
|
11
|
+
ScheduleMixin, SettingsMixin, UrlsMixin, UserInterfaceMixin, InvenTreePlugin
|
|
12
|
+
):
|
|
13
|
+
"""ComponentShortfall - custom InvenTree plugin."""
|
|
14
|
+
|
|
15
|
+
# Plugin metadata
|
|
16
|
+
TITLE = "Component Shortfall"
|
|
17
|
+
NAME = "ComponentShortfall"
|
|
18
|
+
SLUG = "component-shortfall"
|
|
19
|
+
DESCRIPTION = "Generate component shortfall reports"
|
|
20
|
+
VERSION = PLUGIN_VERSION
|
|
21
|
+
|
|
22
|
+
# Additional project information
|
|
23
|
+
AUTHOR = "Oliver Walters"
|
|
24
|
+
WEBSITE = "https://github.com/SchrodingersGat/inventree-shortfall-report"
|
|
25
|
+
LICENSE = "MIT"
|
|
26
|
+
|
|
27
|
+
# Optionally specify supported InvenTree versions
|
|
28
|
+
# MIN_VERSION = '0.18.0'
|
|
29
|
+
# MAX_VERSION = '2.0.0'
|
|
30
|
+
|
|
31
|
+
SCHEDULED_TASKS = {
|
|
32
|
+
"shortfall_report": {"func": "periodic_shortfall_report", "schedule": "D"}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Plugin settings (from SettingsMixin)
|
|
36
|
+
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/settings/
|
|
37
|
+
SETTINGS = {
|
|
38
|
+
"HIDE_NO_SHORTFALL": {
|
|
39
|
+
"name": "Hide No Shortfall",
|
|
40
|
+
"description": "Hide results for parts which have no shortfall",
|
|
41
|
+
"default": True,
|
|
42
|
+
"validator": bool,
|
|
43
|
+
},
|
|
44
|
+
"SHORTFALL_REPORT_DAYS": {
|
|
45
|
+
"name": "Shortfall Report Days",
|
|
46
|
+
"description": "Number of days between automatic shortfall report generation (set to 0 to disable)",
|
|
47
|
+
"default": 7,
|
|
48
|
+
"validator": int,
|
|
49
|
+
},
|
|
50
|
+
"SHORTFALL_REPORT_GROUP": {
|
|
51
|
+
"name": "Shortfall Report Group",
|
|
52
|
+
"description": "User group to send periodic shortfall reports",
|
|
53
|
+
"model": "auth.group",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Custom URL endpoints (from UrlsMixin)
|
|
58
|
+
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/urls/
|
|
59
|
+
def setup_urls(self):
|
|
60
|
+
"""Configure custom URL endpoints for this plugin."""
|
|
61
|
+
from django.urls import path
|
|
62
|
+
from .views import ShortfallReportView
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
# Provide path to a simple custom view - replace this with your own views
|
|
66
|
+
path(
|
|
67
|
+
"shortfall/",
|
|
68
|
+
ShortfallReportView.as_view(),
|
|
69
|
+
name="shortfall-report-view",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# User interface elements (from UserInterfaceMixin)
|
|
74
|
+
# Ref: https://docs.inventree.org/en/latest/plugins/mixins/ui/
|
|
75
|
+
|
|
76
|
+
# Custom dashboard items
|
|
77
|
+
def get_ui_dashboard_items(self, request, context: dict, **kwargs):
|
|
78
|
+
"""Return a list of custom dashboard items to be rendered in the InvenTree user interface."""
|
|
79
|
+
|
|
80
|
+
# Example: only display for 'staff' users
|
|
81
|
+
if not request.user or not request.user.is_staff:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
items = []
|
|
85
|
+
|
|
86
|
+
items.append({
|
|
87
|
+
"key": "component-shortfall-dashboard",
|
|
88
|
+
"title": "Shortfall Report",
|
|
89
|
+
"description": "Generate a component shortfall report",
|
|
90
|
+
"icon": "ti:clipboard-check:outline",
|
|
91
|
+
"source": self.plugin_static_file(
|
|
92
|
+
"Dashboard.js:renderComponentShortfallDashboardItem"
|
|
93
|
+
),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return items
|
|
97
|
+
|
|
98
|
+
def get_ui_spotlight_actions(self, request, context, **kwargs):
|
|
99
|
+
"""Return a list of custom spotlight actions to be made available."""
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
"key": "shortfall-action",
|
|
103
|
+
"title": "Shortfall Report",
|
|
104
|
+
"description": "Generate a component shortfall report",
|
|
105
|
+
"icon": "ti:clipboard-check:outline",
|
|
106
|
+
"source": self.plugin_static_file(
|
|
107
|
+
"Spotlight.js:ComponentShortfallSpotlightAction"
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def periodic_shortfall_report(self):
|
|
113
|
+
"""Scheduled task to periodically generate a shortfall report.
|
|
114
|
+
|
|
115
|
+
This task is called daily, but uses the SHORTFALL_REPORT_DAYS setting to determine how often to actually generate the report.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
import InvenTree.helpers_email
|
|
119
|
+
import InvenTree.tasks
|
|
120
|
+
from common.models import DataOutput
|
|
121
|
+
from .shortfall import calculate_shortfall, format_shortfall_report_html
|
|
122
|
+
from django.contrib.auth.models import Group
|
|
123
|
+
|
|
124
|
+
report_period = int(self.get_setting("SHORTFALL_REPORT_DAYS"))
|
|
125
|
+
|
|
126
|
+
if report_period <= 0:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if not InvenTree.tasks.check_daily_holdoff(
|
|
130
|
+
"component_shortfall_report", report_period
|
|
131
|
+
):
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Run the report generation task here
|
|
135
|
+
data_output = DataOutput.objects.create(
|
|
136
|
+
user=None,
|
|
137
|
+
total=0,
|
|
138
|
+
progress=0,
|
|
139
|
+
output_type="shortfall_report",
|
|
140
|
+
plugin=self.SLUG,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
hide_no_shortfall = self.get_setting("HIDE_NO_SHORTFALL")
|
|
144
|
+
|
|
145
|
+
# Calculate shortfall report with default settings
|
|
146
|
+
requirements = calculate_shortfall(data_output.pk)
|
|
147
|
+
|
|
148
|
+
data_output.refresh_from_db()
|
|
149
|
+
|
|
150
|
+
# Email the report to interested users?
|
|
151
|
+
report_group_id = self.get_setting("SHORTFALL_REPORT_GROUP")
|
|
152
|
+
|
|
153
|
+
users = []
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
group = Group.objects.get(pk=report_group_id)
|
|
157
|
+
users = group.user_set.filter(is_active=True)
|
|
158
|
+
except Group.DoesNotExist:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
recipients = []
|
|
162
|
+
|
|
163
|
+
for user in users:
|
|
164
|
+
if email := InvenTree.helpers_email.get_email_for_user(user):
|
|
165
|
+
if email not in recipients:
|
|
166
|
+
recipients.append(email)
|
|
167
|
+
|
|
168
|
+
# Construct the email body
|
|
169
|
+
body = format_shortfall_report_html(
|
|
170
|
+
requirements, data_output, hide_no_shortfall=hide_no_shortfall
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Send email to users, with the report attached
|
|
174
|
+
if recipients:
|
|
175
|
+
InvenTree.helpers_email.send_email(
|
|
176
|
+
subject="[InvenTree] Component Shortfall Report",
|
|
177
|
+
body="Please find the attached component shortfall report.",
|
|
178
|
+
html_message=body,
|
|
179
|
+
recipients=recipients,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Record success for the task
|
|
183
|
+
InvenTree.tasks.record_task_success("component_shortfall_report")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""API serializers for the ComponentShortfall plugin.
|
|
2
|
+
|
|
3
|
+
In practice, you would define your custom serializers here.
|
|
4
|
+
|
|
5
|
+
Ref: https://www.django-rest-framework.org/api-guide/serializers/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
from rest_framework import serializers
|
|
10
|
+
|
|
11
|
+
import common.serializers
|
|
12
|
+
import part.models as part_models
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ShortfallReportSerializer(serializers.Serializer):
|
|
16
|
+
"""Serializer for shortfall report request parameters."""
|
|
17
|
+
|
|
18
|
+
category = serializers.PrimaryKeyRelatedField(
|
|
19
|
+
queryset=part_models.PartCategory.objects.all(),
|
|
20
|
+
many=False,
|
|
21
|
+
required=False,
|
|
22
|
+
allow_null=True,
|
|
23
|
+
label=_("Category"),
|
|
24
|
+
help_text=_("The category for which to retrieve shortfall data"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
output = common.serializers.DataOutputSerializer(
|
|
28
|
+
read_only=True,
|
|
29
|
+
allow_null=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
max_bom_depth = serializers.IntegerField(
|
|
33
|
+
required=False,
|
|
34
|
+
default=50,
|
|
35
|
+
min_value=0,
|
|
36
|
+
max_value=50,
|
|
37
|
+
label=_("Maximum BOM Depth"),
|
|
38
|
+
help_text=_("The maximum depth to traverse the BOM when calculating shortfall"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def validate(self, data):
|
|
42
|
+
"""Validate the provided data."""
|
|
43
|
+
|
|
44
|
+
# TODO: Any custom data validation goes here
|
|
45
|
+
|
|
46
|
+
return data
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Functions for determining component shortfall.
|
|
2
|
+
|
|
3
|
+
Process Goals:
|
|
4
|
+
|
|
5
|
+
- Determine the overall "requirements" - based on outstanding Sales Orders
|
|
6
|
+
- Iterate downward through the BOMs for each top-level part, to determine the requirements for each sub-component
|
|
7
|
+
- Aggregate the "total" requirements for each component (based on the requirements of all parent assemblies)
|
|
8
|
+
- Determine the shortfall for each component, based on the available stock, on-order quantity and in-production quantity
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
import os
|
|
15
|
+
import structlog
|
|
16
|
+
import tablib
|
|
17
|
+
|
|
18
|
+
from django.core.files.base import ContentFile
|
|
19
|
+
from django.db.models import F
|
|
20
|
+
|
|
21
|
+
from InvenTree.helpers_model import construct_absolute_url
|
|
22
|
+
|
|
23
|
+
import common.models as common_models
|
|
24
|
+
import part.models as part_models
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = structlog.get_logger("inventree.shortfall")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def update_part_requirements(
|
|
31
|
+
part, required_qty: Decimal, component_data: dict
|
|
32
|
+
) -> Decimal:
|
|
33
|
+
"""Return requirements for the given part.
|
|
34
|
+
|
|
35
|
+
Arguments:
|
|
36
|
+
part: The part to process
|
|
37
|
+
required_qty: The additional quantity required for the part
|
|
38
|
+
component_data: A dict of part requirements (may be updated)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The *additional* shortfall for this part (not cumulative)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
requirements = component_data.get(part.pk, None) or {}
|
|
45
|
+
|
|
46
|
+
# Store the part information against the part
|
|
47
|
+
requirements["part"] = part
|
|
48
|
+
|
|
49
|
+
# Fetch (or calculate) the various stock values for this part
|
|
50
|
+
if "stock" not in requirements:
|
|
51
|
+
requirements["stock"] = part.get_stock_count(include_variants=False)
|
|
52
|
+
|
|
53
|
+
# TODO: What about BOM items which allow variants???
|
|
54
|
+
|
|
55
|
+
if "on_order" not in requirements:
|
|
56
|
+
requirements["on_order"] = part.on_order
|
|
57
|
+
|
|
58
|
+
# Add in the additional requirements
|
|
59
|
+
requirements["required"] = requirements.get("required", Decimal(0)) + Decimal(
|
|
60
|
+
required_qty
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# TODO: Support offset for "in production" quantity
|
|
64
|
+
|
|
65
|
+
initial_shortfall = requirements.get("shortfall", Decimal(0))
|
|
66
|
+
|
|
67
|
+
# Calculate the "shortfall" for this part
|
|
68
|
+
requirements["shortfall"] = max(
|
|
69
|
+
0, requirements["required"] - requirements["stock"] - requirements["on_order"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Update the global dict of component data
|
|
73
|
+
component_data[part.pk] = requirements
|
|
74
|
+
|
|
75
|
+
# Return the additional shortfall for this part
|
|
76
|
+
return requirements["shortfall"] - initial_shortfall
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_outstanding_parts(category: Optional[part_models.PartCategory] = None) -> dict:
|
|
80
|
+
"""Return a dict of outstanding parts (based on open sales orders).
|
|
81
|
+
|
|
82
|
+
Returns a dict of part requirements, with the part ID as the key.
|
|
83
|
+
|
|
84
|
+
Each element in the dict has the follow values:
|
|
85
|
+
- part: The part object
|
|
86
|
+
- required: The required quantity of the part (for sales order and build orders)
|
|
87
|
+
|
|
88
|
+
Arguments:
|
|
89
|
+
- category: Optional category to filter the parts by
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from order.models import SalesOrderLineItem
|
|
93
|
+
from order.status_codes import SalesOrderStatusGroups
|
|
94
|
+
|
|
95
|
+
# Find all open sales order line items which are not completed
|
|
96
|
+
sales_order_lines = SalesOrderLineItem.objects.filter(
|
|
97
|
+
order__status__in=SalesOrderStatusGroups.OPEN,
|
|
98
|
+
part__virtual=False,
|
|
99
|
+
shipped__lt=F("quantity"),
|
|
100
|
+
).prefetch_related(
|
|
101
|
+
"part",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# TODO: Filter by order status (e.g. exclude pending orders)
|
|
105
|
+
|
|
106
|
+
# TODO: Filter by order date (e.g. only include orders which are due within a certain time frame)
|
|
107
|
+
|
|
108
|
+
# Filter by part category (e.g. only include orders for parts within a certain category)
|
|
109
|
+
if category:
|
|
110
|
+
categories = category.get_descendants(include_self=True)
|
|
111
|
+
sales_order_lines = sales_order_lines.filter(part__category__in=categories)
|
|
112
|
+
|
|
113
|
+
outstanding_parts = {}
|
|
114
|
+
|
|
115
|
+
for line in sales_order_lines:
|
|
116
|
+
defecit = max(0, line.quantity - line.shipped)
|
|
117
|
+
|
|
118
|
+
if defecit <= 0:
|
|
119
|
+
# No outstanding quantity for this line item
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
part_data = outstanding_parts.get(line.part.pk, None) or {
|
|
123
|
+
"part": line.part,
|
|
124
|
+
"required": Decimal(0),
|
|
125
|
+
}
|
|
126
|
+
part_data["required"] += line.quantity - line.shipped
|
|
127
|
+
outstanding_parts[line.part.pk] = part_data
|
|
128
|
+
|
|
129
|
+
return outstanding_parts
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def calculate_shortfall(
|
|
133
|
+
output_id: int, category_id: Optional[int] = None, max_bom_depth: int = 50
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""Calculate the component shortfall for a given list of component IDs.
|
|
136
|
+
|
|
137
|
+
Arguments:
|
|
138
|
+
output_id: The ID of the DataOutput (where to save the results)
|
|
139
|
+
max_bom_depth: The maximum depth to traverse the BOM when calculating shortfall (default: 50)
|
|
140
|
+
category_id: The ID of the category to filter parts by (optional)
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A dict of part requirements, with the part ID as the key.
|
|
144
|
+
|
|
145
|
+
Each element in the dict has the follow values:
|
|
146
|
+
- part: The part object
|
|
147
|
+
- required: The required quantity of the part (for sales order and build orders)
|
|
148
|
+
- stock: The current stock on hand for this part
|
|
149
|
+
- on_order: The quantity of this part currently on order
|
|
150
|
+
- shortfall: The calculated shortfall for this part (required - stock - on_order)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
logger.info("Generating component shortfall report")
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
data_output = common_models.DataOutput.objects.get(pk=output_id)
|
|
157
|
+
except common_models.DataOutput.DoesNotExist:
|
|
158
|
+
logger.error(
|
|
159
|
+
f"component_shortfall: DataOutput with ID {output_id} does not exist - cannot save results"
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
if category_id:
|
|
165
|
+
category = part_models.PartCategory.objects.get(pk=category_id)
|
|
166
|
+
else:
|
|
167
|
+
category = None
|
|
168
|
+
except (ValueError, part_models.PartCategory.DoesNotExist):
|
|
169
|
+
logger.warning(
|
|
170
|
+
f"component_shortfall: PartCategory with ID {category_id} does not exist - cannot filter parts"
|
|
171
|
+
)
|
|
172
|
+
category = None
|
|
173
|
+
|
|
174
|
+
# First, determine the set of components which are "on order"
|
|
175
|
+
initial_parts = get_outstanding_parts(category=category)
|
|
176
|
+
|
|
177
|
+
# Let's keep track of all the requirements, top-to-bottom, in a single dict - keyed by part ID
|
|
178
|
+
# key: part ID
|
|
179
|
+
# - part: Part instance
|
|
180
|
+
# - required: Total required quantity for this part (cumulative)
|
|
181
|
+
# - stock: Current stock on hand for this part
|
|
182
|
+
# - on_order: Quantity of this part currently on order
|
|
183
|
+
# - building: Quantity of this part currently being built
|
|
184
|
+
requirements = {}
|
|
185
|
+
|
|
186
|
+
# Keep a list of the components still required to process - start with the initial set of outstanding parts
|
|
187
|
+
# Each entry is a tuple of (part, quantity, level)
|
|
188
|
+
components_to_process = []
|
|
189
|
+
|
|
190
|
+
# Start with the initial set of outstanding parts
|
|
191
|
+
for _, data in initial_parts.items():
|
|
192
|
+
part = data["part"]
|
|
193
|
+
required_qty = data["required"]
|
|
194
|
+
|
|
195
|
+
components_to_process.append((part, required_qty, 0))
|
|
196
|
+
|
|
197
|
+
# Update initial conditions for the data output
|
|
198
|
+
data_output.progress = 0
|
|
199
|
+
data_output.total = len(components_to_process)
|
|
200
|
+
data_output.save()
|
|
201
|
+
|
|
202
|
+
while components_to_process:
|
|
203
|
+
part, quantity, level = components_to_process.pop(0)
|
|
204
|
+
data_output.progress += 1
|
|
205
|
+
|
|
206
|
+
shortfall = update_part_requirements(part, quantity, requirements)
|
|
207
|
+
|
|
208
|
+
# Update every 50 iterations
|
|
209
|
+
if data_output.progress % 50 == 0:
|
|
210
|
+
data_output.save()
|
|
211
|
+
|
|
212
|
+
if shortfall <= 0:
|
|
213
|
+
# No shortfall for this part - skip processing any sub-components
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
# Prevent deep recursion into the BOM - if we have reached the maximum level, then we will not process any sub-components
|
|
217
|
+
if level >= max_bom_depth:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Is this an assembly?
|
|
221
|
+
if part.assembly:
|
|
222
|
+
components = (
|
|
223
|
+
part.get_bom_items(include_virtual=False)
|
|
224
|
+
.filter(consumable=False)
|
|
225
|
+
.prefetch_related(
|
|
226
|
+
"sub_part",
|
|
227
|
+
"sub_part__category",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for item in components:
|
|
232
|
+
sub_part = item.sub_part
|
|
233
|
+
|
|
234
|
+
# Calculate the quantity multiplier for this sub-part
|
|
235
|
+
required_qty = item.get_required_quantity(shortfall)
|
|
236
|
+
|
|
237
|
+
components_to_process.append((sub_part, required_qty, level + 1))
|
|
238
|
+
data_output.total += 1
|
|
239
|
+
|
|
240
|
+
# Generate the output data file
|
|
241
|
+
headers = [
|
|
242
|
+
"Part ID",
|
|
243
|
+
"Part Name",
|
|
244
|
+
"Part IPN",
|
|
245
|
+
"Category ID",
|
|
246
|
+
"Category Name",
|
|
247
|
+
"Current Stock",
|
|
248
|
+
"On Order",
|
|
249
|
+
"Required Quantity",
|
|
250
|
+
"Shortfall",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
dataset = tablib.Dataset(headers=headers)
|
|
254
|
+
|
|
255
|
+
for _, data in requirements.items():
|
|
256
|
+
part = data["part"]
|
|
257
|
+
url = construct_absolute_url(part.get_absolute_url())
|
|
258
|
+
|
|
259
|
+
row = [
|
|
260
|
+
f'=HYPERLINK("{url}", "{part.pk}")',
|
|
261
|
+
part.name,
|
|
262
|
+
part.IPN,
|
|
263
|
+
part.category.pk if part.category else None,
|
|
264
|
+
part.category.pathstring if part.category else None,
|
|
265
|
+
data["stock"],
|
|
266
|
+
data["on_order"],
|
|
267
|
+
data["required"],
|
|
268
|
+
data["shortfall"],
|
|
269
|
+
]
|
|
270
|
+
dataset.append(row)
|
|
271
|
+
|
|
272
|
+
# Attach the generated file to the data output
|
|
273
|
+
datafile = dataset.export("xlsx")
|
|
274
|
+
|
|
275
|
+
data_output.mark_complete(
|
|
276
|
+
output=ContentFile(datafile, name="shortfall_report.xlsx")
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return requirements
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def format_shortfall_report_html(
|
|
283
|
+
requirements: dict, output: common_models.DataOutput, hide_no_shortfall: bool = True
|
|
284
|
+
) -> str:
|
|
285
|
+
"""Format the shortfall report as a HTML document."""
|
|
286
|
+
|
|
287
|
+
from django.template import Template, Context
|
|
288
|
+
|
|
289
|
+
file_path = os.path.join(
|
|
290
|
+
os.path.dirname(__file__),
|
|
291
|
+
"templates",
|
|
292
|
+
"component_shortfall",
|
|
293
|
+
"shortfall_email.html",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
with open(file_path, "r") as f:
|
|
297
|
+
template_content = f.read()
|
|
298
|
+
|
|
299
|
+
context_data = {}
|
|
300
|
+
|
|
301
|
+
# Add download link
|
|
302
|
+
if output and output.output:
|
|
303
|
+
context_data["download_url"] = construct_absolute_url(output.output.url)
|
|
304
|
+
|
|
305
|
+
# Add all the requirements entries
|
|
306
|
+
requirements_list = []
|
|
307
|
+
|
|
308
|
+
for entry in requirements.values():
|
|
309
|
+
if hide_no_shortfall and entry.get("shortfall", 0) <= 0:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
requirements_list.append({
|
|
313
|
+
**entry,
|
|
314
|
+
"part_url": construct_absolute_url(entry["part"].get_absolute_url()),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
context_data["requirements"] = requirements_list
|
|
318
|
+
|
|
319
|
+
template = Template(template_content)
|
|
320
|
+
context = Context(context_data)
|
|
321
|
+
|
|
322
|
+
data = template.render(context)
|
|
323
|
+
|
|
324
|
+
return data
|