Coverage for src/backend/InvenTree/build/tasks.py: 17%
192 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Background task definitions for the BuildOrder app."""
3from datetime import timedelta
4from decimal import Decimal
5from typing import Optional
7from django.contrib.auth.models import User
8from django.db import transaction
9from django.utils.translation import gettext_lazy as _
11import structlog
12from opentelemetry import trace
14import common.notifications
15import InvenTree.helpers
16import InvenTree.helpers_model
17import InvenTree.tasks
18from build.events import BuildEvents
19from build.status_codes import BuildStatusGroups
20from InvenTree.ready import isImportingData
21from plugin.events import trigger_event
23tracer = trace.get_tracer(__name__)
24logger = structlog.get_logger('inventree')
27@tracer.start_as_current_span('auto_allocate_build')
28def auto_allocate_build(build_id: int, **kwargs):
29 """Run auto-allocation for a specified BuildOrder."""
30 from build.models import Build
32 build_order = Build.objects.get(pk=build_id)
33 build_order.auto_allocate_stock(**kwargs)
36@tracer.start_as_current_span('consume_build_stock')
37def consume_build_stock(
38 build_id: int,
39 lines: Optional[list[int]] = None,
40 items: Optional[dict] = None,
41 user_id: int | None = None,
42 **kwargs,
43):
44 """Consume stock for the specified BuildOrder.
46 Arguments:
47 build_id: The ID of the BuildOrder to consume stock for
48 lines: Optional list of BuildLine IDs to consume
49 items: Optional dict of BuildItem IDs (and quantities)to consume
50 user_id: The ID of the user who initiated the stock consumption
51 """
52 from build.models import Build, BuildItem, BuildLine
54 build = Build.objects.get(pk=build_id)
55 user = User.objects.filter(pk=user_id).first() if user_id else None
57 lines = lines or []
58 items = items or {}
59 notes = kwargs.pop('notes', '')
61 # Extract the relevant BuildLine and BuildItem objects
62 with transaction.atomic():
63 # Consume each of the specified BuildLine objects
64 for line_id in lines:
65 if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
66 for item in build_line.allocations.all():
67 item.complete_allocation(
68 quantity=item.quantity, notes=notes, user=user
69 )
71 # Consume each of the specified BuildItem objects
72 for item_id, quantity in items.items():
73 if build_item := BuildItem.objects.filter(
74 pk=item_id, build_line__build=build
75 ).first():
76 build_item.complete_allocation(
77 quantity=quantity, notes=notes, user=user
78 )
81@tracer.start_as_current_span('complete_build_allocations')
82def complete_build_allocations(build_id: int, user_id: int):
83 """Complete build allocations for a specified BuildOrder."""
84 from build.models import Build
86 build_order = Build.objects.get(pk=build_id)
88 if user_id:
89 try:
90 user = User.objects.get(pk=user_id)
91 except User.DoesNotExist:
92 user = None
93 logger.warning(
94 'Could not complete build allocations for BuildOrder <%s> - User does not exist',
95 build_id,
96 )
97 else:
98 user = None
100 build_order.complete_allocations(user)
103@tracer.start_as_current_span('delete_build_outputs')
104def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
105 """Delete (cancel) specified build outputs for a BuildOrder.
107 Arguments:
108 build_id: The ID of the BuildOrder
109 output_ids: List of StockItem PKs to delete
110 """
111 from build.models import Build
112 from stock.models import StockItem
114 build = Build.objects.get(pk=build_id)
116 with transaction.atomic():
117 for output_id in output_ids:
118 output = StockItem.objects.filter(pk=output_id).first()
119 if output:
120 build.delete_output(output)
123@tracer.start_as_current_span('scrap_build_outputs')
124def scrap_build_outputs(
125 build_id: int,
126 outputs: list,
127 location_id: int,
128 notes: str = '',
129 discard_allocations: bool = False,
130 user_id: int | None = None,
131 **kwargs,
132):
133 """Scrap specified build outputs for a BuildOrder.
135 Arguments:
136 build_id: The ID of the BuildOrder
137 outputs: List of dicts with 'output_id' and 'quantity'
138 location_id: PK of the destination StockLocation
139 notes: Reason for scrapping
140 discard_allocations: If True, discard (not consume) allocations
141 user_id: PK of the user initiating the action
142 """
143 from build.models import Build
144 from stock.models import StockItem, StockLocation
146 build = Build.objects.get(pk=build_id)
147 location = StockLocation.objects.get(pk=location_id)
148 user = User.objects.filter(pk=user_id).first() if user_id else None
150 with transaction.atomic():
151 for item in outputs:
152 output = StockItem.objects.filter(pk=item['output_id']).first()
153 if output:
154 build.scrap_build_output(
155 output,
156 item.get('quantity'),
157 location,
158 user=user,
159 notes=notes,
160 discard_allocations=discard_allocations,
161 )
164@tracer.start_as_current_span('complete_build_outputs')
165def complete_build_outputs(
166 build_id: int,
167 outputs: list,
168 location_id: int | None,
169 status: int,
170 notes: str = '',
171 user_id: int | None = None,
172 **kwargs,
173):
174 """Complete specified build outputs for a BuildOrder.
176 Arguments:
177 build_id: The ID of the BuildOrder
178 outputs: List of dicts with 'output_id' and optional 'quantity'
179 location_id: PK of the destination StockLocation (or None)
180 status: Stock status code to assign to completed outputs
181 notes: Completion notes
182 user_id: PK of the user initiating the action
183 """
184 from build.models import Build
185 from stock.models import StockItem, StockLocation
187 build = Build.objects.get(pk=build_id)
188 location = (
189 StockLocation.objects.filter(pk=location_id).first() if location_id else None
190 )
191 user = User.objects.filter(pk=user_id).first() if user_id else None
193 required_tests = build.part.getRequiredTests()
195 with transaction.atomic():
196 for item in outputs:
197 output = StockItem.objects.filter(pk=item['output_id']).first()
198 if output:
199 build.complete_build_output(
200 output,
201 user,
202 quantity=item.get('quantity'),
203 location=location,
204 status=status,
205 notes=notes,
206 required_tests=required_tests,
207 )
210@tracer.start_as_current_span('cancel_build')
211def cancel_build(
212 build_id: int,
213 user_id: int,
214 remove_allocated_stock: bool = False,
215 remove_incomplete_outputs: bool = False,
216):
217 """Tasks to run after a BuildOrder is cancelled.
219 Arguments:
220 build_id: The ID of the BuildOrder which has been cancelled
221 user_id: The ID of the user who cancelled the BuildOrder
222 remove_allocated_stock: If True, consume any allocated stock
223 remove_incomplete_outputs: If True, delete any incomplete build outputs
225 """
226 from build.models import Build
228 build = Build.objects.get(pk=build_id)
230 if remove_allocated_stock:
231 complete_build_allocations(build_id, user_id)
232 else:
233 build.allocated_stock.all().delete()
235 if remove_incomplete_outputs:
236 build.build_outputs.filter(is_building=True).delete()
238 # Notify users that the order has been canceled
239 InvenTree.helpers_model.notify_responsible(
240 build,
241 Build,
242 exclude=build.issued_by,
243 content=common.notifications.InvenTreeNotificationBodies.OrderCanceled,
244 extra_users=build.part.get_subscribers(),
245 )
247 trigger_event(BuildEvents.CANCELLED, id=build.pk)
250@tracer.start_as_current_span('complete_build')
251def complete_build(build_id: int, user_id: int, trim_allocated_stock: bool = False):
252 """Tasks to run after a BuildOrder is completed.
254 Arguments:
255 build_id: The ID of the BuildOrder which has been completed
256 user_id: The ID of the user who completed the BuildOrder
257 trim_allocated_stock: If True, trim any allocated stock which was not consumed
258 """
259 from build.models import Build
261 build = Build.objects.get(pk=build_id)
262 user = User.objects.filter(pk=user_id).first() if user_id else None
264 if trim_allocated_stock:
265 build.trim_allocated_stock()
267 # Complete any remaining allocations for this build order
268 complete_build_allocations(build_id, user_id)
270 # Register an event
271 trigger_event(BuildEvents.COMPLETED, id=build.pk)
273 # Notify users that this build has been completed
274 targets = [build.issued_by, build.responsible]
276 # Also inform anyone subscribed to the assembly part
277 targets.extend(build.part.get_subscribers())
279 # Notify those users interested in the parent build
280 if build.parent:
281 targets.append(build.parent.issued_by)
282 targets.append(build.parent.responsible)
284 # Notify users if this build points to a sales order
285 if build.sales_order:
286 targets.append(build.sales_order.created_by)
287 targets.append(build.sales_order.responsible)
289 name = _(f'Build order {build} has been completed')
291 context = {
292 'build': build,
293 'name': name,
294 'slug': 'build.completed',
295 'message': _('A build order has been completed'),
296 'link': InvenTree.helpers_model.construct_absolute_url(
297 build.get_absolute_url()
298 ),
299 'template': {'html': 'email/build_order_completed.html', 'subject': name},
300 }
302 common.notifications.trigger_notification(
303 build,
304 'build.completed',
305 targets=targets,
306 context=context,
307 target_exclude=[user],
308 )
311@tracer.start_as_current_span('update_build_order_lines')
312def update_build_order_lines(bom_item_pk: int):
313 """Update all BuildOrderLineItem objects which reference a particular BomItem.
315 This task is triggered when a BomItem is created or updated.
316 """
317 from build.models import Build, BuildLine
318 from part.models import BomItem
320 logger.info('Updating build order lines for BomItem %s', bom_item_pk)
322 bom_item = BomItem.objects.filter(pk=bom_item_pk).first()
324 # If the BomItem has been deleted, there is nothing to do
325 if not bom_item:
326 return
328 assemblies = bom_item.get_assemblies()
330 # Find all active builds which reference any of the parts
331 builds = Build.objects.filter(
332 part__in=list(assemblies), status__in=BuildStatusGroups.ACTIVE_CODES
333 )
335 # Iterate through each build, and update the relevant line items
336 for bo in builds:
337 # Try to find a matching build order line
338 line = BuildLine.objects.filter(build=bo, bom_item=bom_item).first()
340 q = bom_item.get_required_quantity(bo.quantity)
342 if line:
343 # If the BOM item points to a "virtual" part, delete the BuildLine instance
344 if bom_item.sub_part.virtual:
345 line.delete()
346 continue
348 # Ensure quantity is correct
349 if line.quantity != q:
350 line.quantity = q
351 line.save()
352 elif not bom_item.sub_part.virtual:
353 # Create a new line item (for non-virtual parts)
354 BuildLine.objects.create(build=bo, bom_item=bom_item, quantity=q)
356 if builds.count() > 0:
357 logger.info(
358 'Updated %s build orders for part %s', builds.count(), bom_item.part
359 )
362@tracer.start_as_current_span('check_build_stock')
363def check_build_stock(build):
364 """Check the required stock for a newly created build order.
366 Send an email out to any subscribed users if stock is low.
367 """
368 from part.models import Part
370 # Do not notify if we are importing data
371 if isImportingData():
372 return
374 # Iterate through each of the parts required for this build
376 lines = []
378 if not build:
379 logger.error("Invalid build passed to 'build.tasks.check_build_stock'")
380 return
382 try:
383 part = build.part
384 except Part.DoesNotExist:
385 # Note: This error may be thrown during unit testing...
386 logger.exception("Invalid build.part passed to 'build.tasks.check_build_stock'")
387 return
389 # Iterate through each non-virtual BOM item for this part
390 for bom_item in part.get_bom_items(include_virtual=False):
391 sub_part = bom_item.sub_part
393 # The 'in stock' quantity depends on whether the bom_item allows variants
394 in_stock = sub_part.get_stock_count(include_variants=bom_item.allow_variants)
396 allocated = sub_part.allocation_count()
398 available = max(0, in_stock - allocated)
400 required = Decimal(bom_item.quantity) * Decimal(build.quantity)
402 if available < required:
403 # There is not sufficient stock for this part
405 lines.append({
406 'link': InvenTree.helpers_model.construct_absolute_url(
407 sub_part.get_absolute_url()
408 ),
409 'part': sub_part,
410 'in_stock': in_stock,
411 'allocated': allocated,
412 'available': available,
413 'required': required,
414 })
416 if len(lines) == 0:
417 # Nothing to do
418 return
420 # Are there any users subscribed to these parts?
421 targets = build.part.get_subscribers()
423 if build.responsible:
424 targets.append(build.responsible)
426 name = _('Stock required for build order')
428 context = {
429 'build': build,
430 'name': name,
431 'part': build.part,
432 'lines': lines,
433 'link': InvenTree.helpers_model.construct_absolute_url(
434 build.get_absolute_url()
435 ),
436 'message': _('Build order {build} requires additional stock').format(
437 build=build
438 ),
439 'template': {'html': 'email/build_order_required_stock.html', 'subject': name},
440 }
442 common.notifications.trigger_notification(
443 build, BuildEvents.STOCK_REQUIRED, targets=targets, context=context
444 )
447@tracer.start_as_current_span('notify_overdue_build_order')
448def notify_overdue_build_order(bo):
449 """Notify appropriate users that a Build has just become 'overdue'."""
450 targets = []
452 if bo.issued_by:
453 targets.append(bo.issued_by)
455 if bo.responsible:
456 targets.append(bo.responsible)
458 targets.extend(bo.part.get_subscribers())
460 name = _('Overdue Build Order')
462 context = {
463 'order': bo,
464 'name': name,
465 'message': _(f'Build order {bo} is now overdue'),
466 'link': InvenTree.helpers_model.construct_absolute_url(bo.get_absolute_url()),
467 'template': {'html': 'email/overdue_build_order.html', 'subject': name},
468 }
470 event_name = BuildEvents.OVERDUE
472 # Send a notification to the appropriate users
473 common.notifications.trigger_notification(
474 bo, event_name, targets=targets, context=context
475 )
477 # Register a matching event to the plugin system
478 trigger_event(event_name, build_order=bo.pk)
481@tracer.start_as_current_span('check_overdue_build_orders')
482@InvenTree.tasks.scheduled_task(InvenTree.tasks.ScheduledTask.DAILY)
483def check_overdue_build_orders():
484 """Check if any outstanding BuildOrders have just become overdue.
486 - This check is performed daily
487 - Look at the 'target_date' of any outstanding BuildOrder objects
488 - If the 'target_date' expired *yesterday* then the order is just out of date
489 """
490 from build.models import Build
492 yesterday = InvenTree.helpers.current_date() - timedelta(days=1)
494 overdue_orders = Build.objects.filter(
495 target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES
496 )
498 for bo in overdue_orders:
499 notify_overdue_build_order(bo)