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

1"""Background task definitions for the BuildOrder app.""" 

2 

3from datetime import timedelta 

4from decimal import Decimal 

5from typing import Optional 

6 

7from django.contrib.auth.models import User 

8from django.db import transaction 

9from django.utils.translation import gettext_lazy as _ 

10 

11import structlog 

12from opentelemetry import trace 

13 

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 

22 

23tracer = trace.get_tracer(__name__) 

24logger = structlog.get_logger('inventree') 

25 

26 

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 

31 

32 build_order = Build.objects.get(pk=build_id) 

33 build_order.auto_allocate_stock(**kwargs) 

34 

35 

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. 

45 

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 

53 

54 build = Build.objects.get(pk=build_id) 

55 user = User.objects.filter(pk=user_id).first() if user_id else None 

56 

57 lines = lines or [] 

58 items = items or {} 

59 notes = kwargs.pop('notes', '') 

60 

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 ) 

70 

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 ) 

79 

80 

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 

85 

86 build_order = Build.objects.get(pk=build_id) 

87 

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 

99 

100 build_order.complete_allocations(user) 

101 

102 

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. 

106 

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 

113 

114 build = Build.objects.get(pk=build_id) 

115 

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) 

121 

122 

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. 

134 

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 

145 

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 

149 

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 ) 

162 

163 

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. 

175 

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 

186 

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 

192 

193 required_tests = build.part.getRequiredTests() 

194 

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 ) 

208 

209 

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. 

218 

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 

224 

225 """ 

226 from build.models import Build 

227 

228 build = Build.objects.get(pk=build_id) 

229 

230 if remove_allocated_stock: 

231 complete_build_allocations(build_id, user_id) 

232 else: 

233 build.allocated_stock.all().delete() 

234 

235 if remove_incomplete_outputs: 

236 build.build_outputs.filter(is_building=True).delete() 

237 

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 ) 

246 

247 trigger_event(BuildEvents.CANCELLED, id=build.pk) 

248 

249 

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. 

253 

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 

260 

261 build = Build.objects.get(pk=build_id) 

262 user = User.objects.filter(pk=user_id).first() if user_id else None 

263 

264 if trim_allocated_stock: 

265 build.trim_allocated_stock() 

266 

267 # Complete any remaining allocations for this build order 

268 complete_build_allocations(build_id, user_id) 

269 

270 # Register an event 

271 trigger_event(BuildEvents.COMPLETED, id=build.pk) 

272 

273 # Notify users that this build has been completed 

274 targets = [build.issued_by, build.responsible] 

275 

276 # Also inform anyone subscribed to the assembly part 

277 targets.extend(build.part.get_subscribers()) 

278 

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) 

283 

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) 

288 

289 name = _(f'Build order {build} has been completed') 

290 

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 } 

301 

302 common.notifications.trigger_notification( 

303 build, 

304 'build.completed', 

305 targets=targets, 

306 context=context, 

307 target_exclude=[user], 

308 ) 

309 

310 

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. 

314 

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 

319 

320 logger.info('Updating build order lines for BomItem %s', bom_item_pk) 

321 

322 bom_item = BomItem.objects.filter(pk=bom_item_pk).first() 

323 

324 # If the BomItem has been deleted, there is nothing to do 

325 if not bom_item: 

326 return 

327 

328 assemblies = bom_item.get_assemblies() 

329 

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 ) 

334 

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() 

339 

340 q = bom_item.get_required_quantity(bo.quantity) 

341 

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 

347 

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) 

355 

356 if builds.count() > 0: 

357 logger.info( 

358 'Updated %s build orders for part %s', builds.count(), bom_item.part 

359 ) 

360 

361 

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. 

365 

366 Send an email out to any subscribed users if stock is low. 

367 """ 

368 from part.models import Part 

369 

370 # Do not notify if we are importing data 

371 if isImportingData(): 

372 return 

373 

374 # Iterate through each of the parts required for this build 

375 

376 lines = [] 

377 

378 if not build: 

379 logger.error("Invalid build passed to 'build.tasks.check_build_stock'") 

380 return 

381 

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 

388 

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 

392 

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) 

395 

396 allocated = sub_part.allocation_count() 

397 

398 available = max(0, in_stock - allocated) 

399 

400 required = Decimal(bom_item.quantity) * Decimal(build.quantity) 

401 

402 if available < required: 

403 # There is not sufficient stock for this part 

404 

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 }) 

415 

416 if len(lines) == 0: 

417 # Nothing to do 

418 return 

419 

420 # Are there any users subscribed to these parts? 

421 targets = build.part.get_subscribers() 

422 

423 if build.responsible: 

424 targets.append(build.responsible) 

425 

426 name = _('Stock required for build order') 

427 

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 } 

441 

442 common.notifications.trigger_notification( 

443 build, BuildEvents.STOCK_REQUIRED, targets=targets, context=context 

444 ) 

445 

446 

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 = [] 

451 

452 if bo.issued_by: 

453 targets.append(bo.issued_by) 

454 

455 if bo.responsible: 

456 targets.append(bo.responsible) 

457 

458 targets.extend(bo.part.get_subscribers()) 

459 

460 name = _('Overdue Build Order') 

461 

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 } 

469 

470 event_name = BuildEvents.OVERDUE 

471 

472 # Send a notification to the appropriate users 

473 common.notifications.trigger_notification( 

474 bo, event_name, targets=targets, context=context 

475 ) 

476 

477 # Register a matching event to the plugin system 

478 trigger_event(event_name, build_order=bo.pk) 

479 

480 

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. 

485 

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 

491 

492 yesterday = InvenTree.helpers.current_date() - timedelta(days=1) 

493 

494 overdue_orders = Build.objects.filter( 

495 target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES 

496 ) 

497 

498 for bo in overdue_orders: 

499 notify_overdue_build_order(bo)