Coverage for src/backend/InvenTree/build/models.py: 22%

739 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Build database model definitions.""" 

2 

3import decimal 

4from typing import Optional, TypedDict 

5 

6from django.contrib.auth.models import User 

7from django.core.exceptions import ValidationError 

8from django.core.validators import MinValueValidator 

9from django.db import models, transaction 

10from django.db.models import F, Q, QuerySet, Sum 

11from django.db.models.functions import Coalesce 

12from django.db.models.signals import post_save 

13from django.dispatch.dispatcher import receiver 

14from django.urls import reverse 

15from django.utils.translation import gettext_lazy as _ 

16 

17import structlog 

18from mptt.models import TreeForeignKey 

19from rest_framework import serializers 

20 

21import generic.states 

22import InvenTree.fields 

23import InvenTree.helpers 

24import InvenTree.helpers_model 

25import InvenTree.models 

26import InvenTree.ready 

27import InvenTree.tasks 

28import part.models 

29import report.mixins 

30import stock.models 

31import users.models 

32from build.events import BuildEvents 

33from build.filters import annotate_allocated_quantity, annotate_required_quantity 

34from build.status_codes import BuildStatus, BuildStatusGroups 

35from build.validators import ( 

36 generate_next_build_reference, 

37 validate_build_order_reference, 

38) 

39from common.models import ProjectCode 

40from common.settings import ( 

41 get_global_setting, 

42 prevent_build_output_complete_on_incompleted_tests, 

43) 

44from generic.enums import StringEnum 

45from generic.states import StateTransitionMixin, StatusCodeMixin 

46from plugin.events import trigger_event 

47from stock.status_codes import StockHistoryCode, StockStatus 

48 

49logger = structlog.get_logger('inventree') 

50 

51 

52class BuildReportContext(report.mixins.BaseReportContext, TypedDict): 

53 """Context for the Build model. 

54 

55 Attributes: 

56 bom_items: Query set of all BuildItem objects associated with the BuildOrder 

57 build: The BuildOrder instance itself 

58 build_outputs: Query set of all BuildItem objects associated with the BuildOrder 

59 line_items: Query set of all build line items associated with the BuildOrder 

60 part: The Part object which is being assembled in the build order 

61 quantity: The total quantity of the part being assembled 

62 reference: The reference field of the BuildOrder 

63 title: The title field of the BuildOrder 

64 """ 

65 

66 bom_items: report.mixins.QuerySet[part.models.BomItem] 

67 build: 'Build' 

68 build_outputs: report.mixins.QuerySet[stock.models.StockItem] 

69 line_items: report.mixins.QuerySet['BuildLine'] 

70 part: part.models.Part 

71 quantity: int 

72 reference: str 

73 title: str 

74 

75 

76class Build( 

77 InvenTree.models.PluginValidationMixin, 

78 report.mixins.InvenTreeReportMixin, 

79 InvenTree.models.InvenTreeParameterMixin, 

80 InvenTree.models.InvenTreeAttachmentMixin, 

81 InvenTree.models.InvenTreeBarcodeMixin, 

82 InvenTree.models.InvenTreeTagsMixin, 

83 InvenTree.models.InvenTreeNotesMixin, 

84 InvenTree.models.ReferenceIndexingMixin, 

85 StateTransitionMixin, 

86 StatusCodeMixin, 

87 InvenTree.models.MetadataMixin, 

88 InvenTree.models.InvenTreeTree, 

89): 

90 """A Build object organizes the creation of new StockItem objects from other existing StockItem objects. 

91 

92 Attributes: 

93 part: The part to be built (from component BOM items) 

94 reference: Build order reference (required, must be unique) 

95 title: Brief title describing the build (optional) 

96 quantity: Number of units to be built 

97 parent: Reference to a Build object for which this Build is required 

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

99 take_from: Location to take stock from to make this build (if blank, can take from anywhere) 

100 status: Build status code 

101 external: Set to indicate that this build order is fulfilled externally 

102 batch: Batch code transferred to build parts (optional) 

103 creation_date: Date the build was created (auto) 

104 target_date: Date the build will be overdue 

105 completion_date: Date the build was completed (or, if incomplete, the expected date of completion) 

106 link: External URL for extra information 

107 notes: Text notes 

108 completed_by: User that completed the build 

109 issued_by: User that issued the build 

110 responsible: User (or group) responsible for completing the build 

111 priority: Priority of the build 

112 """ 

113 

114 STATUS_CLASS = BuildStatus 

115 IMPORT_ID_FIELDS = ['reference'] 

116 

117 class Meta: 

118 """Metaclass options for the BuildOrder model.""" 

119 

120 verbose_name = _('Build Order') 

121 verbose_name_plural = _('Build Orders') 

122 

123 class MPTTMeta: 

124 """MPTT options for the BuildOrder model.""" 

125 

126 order_insertion_by = ['reference'] 

127 

128 class BuildItemTypes(StringEnum): 

129 """Enumeration of available item types.""" 

130 

131 ALL = 'all' # All BOM items (both tracked and untracked) 

132 TRACKED = 'tracked' # Tracked BOM items 

133 UNTRACKED = 'untracked' # Untracked BOM items 

134 

135 @classmethod 

136 def get_overdue_filter(cls): 

137 """Filter for determining if a build order is overdue.""" 

138 return ( 

139 Q(status__in=BuildStatusGroups.ACTIVE_CODES) 

140 & ~Q(target_date=None) 

141 & Q(target_date__lte=InvenTree.helpers.current_date()) 

142 ) 

143 

144 # Global setting for specifying reference pattern 

145 REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' 

146 

147 @staticmethod 

148 def get_api_url(): 

149 """Return the API URL associated with the BuildOrder model.""" 

150 return reverse('api-build-list') 

151 

152 def api_instance_filters(self): 

153 """Returns custom API filters for the particular BuildOrder instance.""" 

154 return {'parent': {'exclude_tree': self.pk}} 

155 

156 @classmethod 

157 def api_defaults(cls, request=None): 

158 """Return default values for this model when issuing an API OPTIONS request.""" 

159 defaults = {'reference': generate_next_build_reference()} 

160 

161 if request and request.user: 161 ↛ 164line 161 didn't jump to line 164 because the condition on line 161 was always true

162 defaults['issued_by'] = request.user.pk 

163 

164 return defaults 

165 

166 @classmethod 

167 def barcode_model_type_code(cls): 

168 """Return the associated barcode model type code for this model.""" 

169 return 'BO' 

170 

171 def save(self, *args, **kwargs): 

172 """Custom save method for the BuildOrder model.""" 

173 self.reference_int = self.validate_reference_field(self.reference) 

174 

175 # Check part when initially creating the build order 

176 if not self.pk or self.has_field_changed('part'): 

177 if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'): 

178 # Check that the BOM is valid 

179 if not self.part.is_bom_valid(): 

180 raise ValidationError({ 

181 'part': _('Assembly BOM has not been validated') 

182 }) 

183 

184 if get_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART'): 

185 # Check that the part is active 

186 if not self.part.active: 

187 raise ValidationError({ 

188 'part': _('Build order cannot be created for an inactive part') 

189 }) 

190 

191 if get_global_setting('BUILDORDER_REQUIRE_LOCKED_PART'): 

192 # Check that the part is locked 

193 if not self.part.locked: 

194 raise ValidationError({ 

195 'part': _('Build order cannot be created for an unlocked part') 

196 }) 

197 

198 # On first save (i.e. creation), run some extra checks 

199 if self.pk is None: 

200 # Set the destination location (if not specified) 

201 if not self.destination: 

202 self.destination = self.part.get_default_location() 

203 

204 super().save(*args, **kwargs) 

205 

206 def clean(self): 

207 """Validate the BuildOrder model.""" 

208 super().clean() 

209 

210 if self.external and not self.part.purchaseable: 

211 raise ValidationError({ 

212 'external': _( 

213 'Build orders can only be externally fulfilled for purchaseable parts' 

214 ) 

215 }) 

216 

217 if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'): 

218 if not self.responsible: 

219 raise ValidationError({ 

220 'responsible': _('Responsible user or group must be specified') 

221 }) 

222 

223 # Prevent changing target part after creation 

224 if self.has_field_changed('part'): 

225 raise ValidationError({'part': _('Build order part cannot be changed')}) 

226 

227 # Target date should be *after* the start date 

228 if self.start_date and self.target_date and self.start_date > self.target_date: 

229 raise ValidationError({ 

230 'target_date': _('Target date must be after start date') 

231 }) 

232 

233 def report_context(self) -> BuildReportContext: 

234 """Generate custom report context data.""" 

235 return { 

236 'bom_items': self.part.get_bom_items(), 

237 'build': self, 

238 'build_outputs': self.build_outputs.all(), 

239 'line_items': self.build_lines.all(), 

240 'part': self.part, 

241 'quantity': self.quantity, 

242 'reference': self.reference, 

243 'title': str(self), 

244 } 

245 

246 def __str__(self): 

247 """String representation of a BuildOrder.""" 

248 return self.reference 

249 

250 def get_absolute_url(self): 

251 """Return the web URL associated with this BuildOrder.""" 

252 return InvenTree.helpers.pui_url(f'/manufacturing/build-order/{self.id}') 

253 

254 reference = models.CharField( 

255 unique=True, 

256 max_length=64, 

257 blank=False, 

258 help_text=_('Build Order Reference'), 

259 verbose_name=_('Reference'), 

260 default=generate_next_build_reference, 

261 validators=[validate_build_order_reference], 

262 ) 

263 

264 title = models.CharField( 

265 verbose_name=_('Description'), 

266 blank=True, 

267 max_length=100, 

268 help_text=_('Brief description of the build (optional)'), 

269 ) 

270 

271 parent = TreeForeignKey( 

272 'self', 

273 on_delete=models.SET_NULL, 

274 blank=True, 

275 null=True, 

276 related_name='children', 

277 verbose_name=_('Parent Build'), 

278 help_text=_('Build Order to which this build is allocated'), 

279 ) 

280 

281 part = models.ForeignKey( 

282 'part.Part', 

283 verbose_name=_('Part'), 

284 on_delete=models.CASCADE, 

285 related_name='builds', 

286 limit_choices_to={'assembly': True}, 

287 help_text=_('Select part to build'), 

288 ) 

289 

290 sales_order = models.ForeignKey( 

291 'order.SalesOrder', 

292 verbose_name=_('Sales Order Reference'), 

293 on_delete=models.SET_NULL, 

294 related_name='builds', 

295 null=True, 

296 blank=True, 

297 help_text=_('Sales Order to which this build is allocated'), 

298 ) 

299 

300 take_from = models.ForeignKey( 

301 'stock.StockLocation', 

302 verbose_name=_('Source Location'), 

303 on_delete=models.SET_NULL, 

304 related_name='sourcing_builds', 

305 null=True, 

306 blank=True, 

307 help_text=_( 

308 'Select location to take stock from for this build (leave blank to take from any stock location)' 

309 ), 

310 ) 

311 

312 external = models.BooleanField( 

313 default=False, 

314 verbose_name=_('External Build'), 

315 help_text=_('This build order is fulfilled externally'), 

316 ) 

317 

318 destination = models.ForeignKey( 

319 'stock.StockLocation', 

320 verbose_name=_('Destination Location'), 

321 on_delete=models.SET_NULL, 

322 related_name='incoming_builds', 

323 null=True, 

324 blank=True, 

325 help_text=_('Select location where the completed items will be stored'), 

326 ) 

327 

328 quantity = models.PositiveIntegerField( 

329 verbose_name=_('Build Quantity'), 

330 default=1, 

331 validators=[MinValueValidator(1)], 

332 help_text=_('Number of stock items to build'), 

333 ) 

334 

335 completed = models.PositiveIntegerField( 

336 verbose_name=_('Completed items'), 

337 default=0, 

338 help_text=_('Number of stock items which have been completed'), 

339 ) 

340 

341 status = generic.states.fields.InvenTreeCustomStatusModelField( 

342 verbose_name=_('Build Status'), 

343 default=BuildStatus.PENDING.value, 

344 choices=BuildStatus.items(), 

345 status_class=BuildStatus, 

346 validators=[MinValueValidator(0)], 

347 help_text=_('Build status code'), 

348 ) 

349 

350 @property 

351 def status_text(self): 

352 """Return the text representation of the status field.""" 

353 return BuildStatus.text(self.status) 

354 

355 batch = models.CharField( 

356 verbose_name=_('Batch Code'), 

357 max_length=100, 

358 blank=True, 

359 null=True, 

360 help_text=_('Batch code for this build output'), 

361 ) 

362 

363 creation_date = models.DateField( 

364 auto_now_add=True, editable=False, verbose_name=_('Creation Date') 

365 ) 

366 

367 start_date = models.DateField( 

368 null=True, 

369 blank=True, 

370 verbose_name=_('Build start date'), 

371 help_text=_('Scheduled start date for this build order'), 

372 ) 

373 

374 target_date = models.DateField( 

375 null=True, 

376 blank=True, 

377 verbose_name=_('Target completion date'), 

378 help_text=_( 

379 'Target date for build completion. Build will be overdue after this date.' 

380 ), 

381 ) 

382 

383 completion_date = models.DateField( 

384 null=True, blank=True, verbose_name=_('Completion Date') 

385 ) 

386 

387 completed_by = models.ForeignKey( 

388 User, 

389 on_delete=models.SET_NULL, 

390 blank=True, 

391 null=True, 

392 verbose_name=_('completed by'), 

393 related_name='builds_completed', 

394 ) 

395 

396 issued_by = models.ForeignKey( 

397 User, 

398 on_delete=models.SET_NULL, 

399 blank=True, 

400 null=True, 

401 verbose_name=_('Issued by'), 

402 help_text=_('User who issued this build order'), 

403 related_name='builds_issued', 

404 ) 

405 

406 @property 

407 def created_by(self): 

408 """Alias for issued_by field. 

409 

410 This is used for compatibility with the order models 

411 """ 

412 return self.issued_by 

413 

414 responsible = models.ForeignKey( 

415 users.models.Owner, 

416 on_delete=models.SET_NULL, 

417 blank=True, 

418 null=True, 

419 verbose_name=_('Responsible'), 

420 help_text=_('User or group responsible for this build order'), 

421 related_name='builds_responsible', 

422 ) 

423 

424 link = InvenTree.fields.InvenTreeURLField( 

425 verbose_name=_('External Link'), 

426 blank=True, 

427 help_text=_('Link to external URL'), 

428 max_length=2000, 

429 ) 

430 

431 priority = models.PositiveIntegerField( 

432 verbose_name=_('Build Priority'), 

433 default=0, 

434 validators=[MinValueValidator(0)], 

435 help_text=_('Priority of this build order'), 

436 ) 

437 

438 project_code = models.ForeignKey( 

439 ProjectCode, 

440 on_delete=models.SET_NULL, 

441 blank=True, 

442 null=True, 

443 verbose_name=_('Project Code'), 

444 help_text=_('Project code for this build order'), 

445 ) 

446 

447 def sub_builds(self, cascade: bool = True) -> QuerySet: 

448 """Return all Build Order objects under this one.""" 

449 if cascade: 

450 return self.get_descendants(include_self=False) 

451 else: 

452 return self.get_children() 

453 

454 def sub_build_count(self, cascade: bool = True) -> int: 

455 """Return the number of sub builds under this one. 

456 

457 Args: 

458 cascade: If True (default), include cascading builds under sub builds 

459 """ 

460 return self.sub_builds(cascade=cascade).count() 

461 

462 @property 

463 def has_open_child_builds(self) -> bool: 

464 """Return True if this build order has any open child builds.""" 

465 return ( 

466 self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists() 

467 ) 

468 

469 @property 

470 def is_overdue(self) -> bool: 

471 """Returns true if this build is "overdue". 

472 

473 Makes use of the OVERDUE_FILTER to avoid code duplication 

474 

475 Returns: 

476 bool: Is the build overdue 

477 """ 

478 query = Build.objects.filter(pk=self.pk) 

479 query = query.filter(Build.get_overdue_filter()) 

480 

481 return query.exists() 

482 

483 @property 

484 def active(self) -> bool: 

485 """Return True if this build is active.""" 

486 return self.status in BuildStatusGroups.ACTIVE_CODES 

487 

488 @property 

489 def tracked_line_items(self) -> QuerySet: 

490 """Returns the "trackable" BOM lines for this BuildOrder.""" 

491 return self.build_lines.filter(bom_item__sub_part__trackable=True) 

492 

493 def has_tracked_line_items(self) -> bool: 

494 """Returns True if this BuildOrder has trackable BomItems.""" 

495 return self.tracked_line_items.count() > 0 

496 

497 @property 

498 def untracked_line_items(self) -> QuerySet: 

499 """Returns the "non trackable" BOM items for this BuildOrder.""" 

500 return self.build_lines.filter(bom_item__sub_part__trackable=False) 

501 

502 @property 

503 def are_untracked_parts_allocated(self) -> bool: 

504 """Returns True if all untracked parts are allocated for this BuildOrder.""" 

505 return self.is_fully_allocated(tracked=False) 

506 

507 def has_untracked_line_items(self) -> bool: 

508 """Returns True if this BuildOrder has non trackable BomItems.""" 

509 return self.has_untracked_line_items.count() > 0 

510 

511 @property 

512 def remaining(self): 

513 """Return the number of outputs remaining to be completed.""" 

514 return max(0, self.quantity - self.completed) 

515 

516 @property 

517 def output_count(self) -> int: 

518 """Return the number of build outputs (StockItem) associated with this build order.""" 

519 return self.build_outputs.count() 

520 

521 def has_build_outputs(self) -> bool: 

522 """Returns True if this build has more than zero build outputs.""" 

523 return self.output_count > 0 

524 

525 def get_build_outputs(self, **kwargs) -> QuerySet: 

526 """Return a list of build outputs. 

527 

528 kwargs: 

529 complete = (True / False) - If supplied, filter by completed status 

530 in_stock = (True / False) - If supplied, filter by 'in-stock' status 

531 """ 

532 outputs = self.build_outputs.all() 

533 

534 # Filter by 'in stock' status 

535 in_stock = kwargs.get('in_stock') 

536 

537 if in_stock is not None: 

538 if in_stock: 

539 outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER) 

540 else: 

541 outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER) 

542 

543 # Filter by 'complete' status 

544 complete = kwargs.get('complete') 

545 

546 if complete is not None: 

547 if complete: 

548 outputs = outputs.filter(is_building=False) 

549 else: 

550 outputs = outputs.filter(is_building=True) 

551 

552 return outputs 

553 

554 @property 

555 def complete_outputs(self) -> QuerySet: 

556 """Return all the "completed" build outputs.""" 

557 outputs = self.get_build_outputs(complete=True) 

558 

559 return outputs 

560 

561 @property 

562 def complete_count(self): 

563 """Return the total quantity of completed outputs.""" 

564 quantity = 0 

565 

566 for output in self.complete_outputs: 

567 quantity += output.quantity 

568 

569 return quantity 

570 

571 def is_partially_allocated(self) -> bool: 

572 """Test is this build order has any stock allocated against it.""" 

573 return self.allocated_stock.count() > 0 

574 

575 @property 

576 def incomplete_outputs(self) -> QuerySet: 

577 """Return all the "incomplete" build outputs.""" 

578 outputs = self.get_build_outputs(complete=False) 

579 

580 return outputs 

581 

582 @property 

583 def incomplete_count(self): 

584 """Return the total number of "incomplete" outputs.""" 

585 quantity = 0 

586 

587 for output in self.incomplete_outputs: 

588 quantity += output.quantity 

589 

590 return quantity 

591 

592 @classmethod 

593 def getNextBuildNumber(cls): 

594 """Try to predict the next Build Order reference.""" 

595 if cls.objects.count() == 0: 

596 return None 

597 

598 # Extract the "most recent" build order reference 

599 builds = cls.objects.exclude(reference=None) 

600 

601 if not builds.exists(): 

602 return None 

603 

604 build = builds.last() 

605 ref = build.reference 

606 

607 if not ref: 

608 return None 

609 

610 tries = set(ref) 

611 

612 new_ref = ref 

613 

614 while 1: 

615 new_ref = InvenTree.helpers.increment(new_ref) 

616 

617 if new_ref in tries: 

618 # We are potentially stuck in a loop - simply return the original reference 

619 return ref 

620 

621 # Check if the existing build reference exists 

622 if cls.objects.filter(reference=new_ref).exists(): 

623 tries.add(new_ref) 

624 else: 

625 break 

626 

627 return new_ref 

628 

629 @property 

630 def can_complete(self) -> bool: 

631 """Returns True if this BuildOrder is ready to be completed. 

632 

633 - Must not have any outstanding build outputs 

634 - Completed count must meet the required quantity 

635 - Untracked parts must be allocated 

636 """ 

637 if ( 

638 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') 

639 and self.has_open_child_builds 

640 ): 

641 return False 

642 

643 if self.status != BuildStatus.PRODUCTION.value: 

644 return False 

645 

646 if self.incomplete_count > 0: 

647 return False 

648 

649 if self.remaining > 0: 

650 return False 

651 

652 return self.is_fully_allocated(tracked=False) 

653 

654 def complete_allocations(self, user) -> None: 

655 """Complete all stock allocations for this build order. 

656 

657 - This function is called when a build order is completed 

658 """ 

659 # Remove untracked allocated stock 

660 self.subtract_allocated_stock(user) 

661 

662 # Ensure that there are no longer any BuildItem objects 

663 # which point to this Build Order 

664 self.allocated_stock.all().delete() 

665 

666 @transaction.atomic 

667 def complete_build(self, user: User, trim_allocated_stock: bool = False): 

668 """Mark this build as complete. 

669 

670 Arguments: 

671 user: The user who is completing the build 

672 trim_allocated_stock: If True, trim any allocated stock 

673 """ 

674 return self.handle_transition( 

675 self.status, 

676 BuildStatus.COMPLETE.value, 

677 self, 

678 self._action_complete, 

679 user=user, 

680 trim_allocated_stock=trim_allocated_stock, 

681 ) 

682 

683 def _action_complete(self, *args, **kwargs): 

684 """Action to be taken when a build is completed.""" 

685 import build.tasks 

686 

687 trim_allocated_stock = kwargs.pop('trim_allocated_stock', False) 

688 user = kwargs.pop('user', None) 

689 

690 # Prevent completion if there are open child builds 

691 if ( 

692 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') 

693 and self.has_open_child_builds 

694 ): 

695 raise ValidationError( 

696 _('Cannot complete build order with open child builds') 

697 ) 

698 

699 if self.incomplete_count > 0: 

700 raise ValidationError( 

701 _('Cannot complete build order with incomplete outputs') 

702 ) 

703 

704 # Offload background task to complete build allocations 

705 InvenTree.tasks.offload_task( 

706 build.tasks.complete_build, 

707 self.pk, 

708 user.pk if user else None, 

709 trim_allocated_stock=trim_allocated_stock, 

710 group='build', 

711 ) 

712 

713 self.completion_date = InvenTree.helpers.current_date() 

714 self.completed_by = user 

715 self.status = BuildStatus.COMPLETE.value 

716 self.save() 

717 

718 @transaction.atomic 

719 def issue_build(self): 

720 """Mark the Build as IN PRODUCTION. 

721 

722 Args: 

723 user: The user who is issuing the build 

724 """ 

725 return self.handle_transition( 

726 self.status, BuildStatus.PENDING.value, self, self._action_issue 

727 ) 

728 

729 @property 

730 def can_issue(self) -> bool: 

731 """Returns True if this BuildOrder can be issued.""" 

732 return self.status in [BuildStatus.PENDING.value, BuildStatus.ON_HOLD.value] 

733 

734 def _action_issue(self, *args, **kwargs): 

735 """Perform the action to mark this order as PRODUCTION.""" 

736 if self.can_issue: 

737 self.status = BuildStatus.PRODUCTION.value 

738 self.save() 

739 

740 trigger_event(BuildEvents.ISSUED, id=self.pk) 

741 

742 from build.tasks import check_build_stock 

743 

744 # Run checks on required parts 

745 InvenTree.tasks.offload_task( 

746 check_build_stock, self, group='build', force_async=True 

747 ) 

748 

749 @transaction.atomic 

750 def hold_build(self): 

751 """Mark the Build as ON HOLD.""" 

752 return self.handle_transition( 

753 self.status, BuildStatus.ON_HOLD.value, self, self._action_hold 

754 ) 

755 

756 @property 

757 def can_hold(self) -> bool: 

758 """Returns True if this BuildOrder can be placed on hold.""" 

759 return self.status in [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value] 

760 

761 def _action_hold(self, *args, **kwargs): 

762 """Action to be taken when a build is placed on hold.""" 

763 if self.can_hold: 

764 self.status = BuildStatus.ON_HOLD.value 

765 self.save() 

766 

767 trigger_event(BuildEvents.HOLD, id=self.pk) 

768 

769 @transaction.atomic 

770 def cancel_build(self, user, **kwargs): 

771 """Mark the Build as CANCELLED. 

772 

773 - Delete any pending BuildItem objects (but do not remove items from stock) 

774 - Set build status to CANCELLED 

775 - Save the Build object 

776 """ 

777 return self.handle_transition( 

778 self.status, 

779 BuildStatus.CANCELLED.value, 

780 self, 

781 self._action_cancel, 

782 user=user, 

783 **kwargs, 

784 ) 

785 

786 def _action_cancel(self, *args, **kwargs): 

787 """Action to be taken when a build is cancelled.""" 

788 import build.tasks 

789 

790 user = kwargs.pop('user', None) 

791 

792 remove_allocated_stock = kwargs.get('remove_allocated_stock', False) 

793 remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) 

794 

795 # Offload background task to take care of the expensive operations 

796 InvenTree.tasks.offload_task( 

797 build.tasks.cancel_build, 

798 self.pk, 

799 user.pk if user else None, 

800 remove_allocated_stock=remove_allocated_stock, 

801 remove_incomplete_outputs=remove_incomplete_outputs, 

802 group='build', 

803 ) 

804 

805 # Date of 'completion' is the date the build was cancelled 

806 self.completion_date = InvenTree.helpers.current_date() 

807 self.completed_by = user 

808 

809 self.status = BuildStatus.CANCELLED.value 

810 self.save() 

811 

812 @transaction.atomic 

813 def deallocate_stock(self, build_line=None, output=None): 

814 """Deallocate stock from this Build. 

815 

816 Args: 

817 build_line: Specify a particular BuildLine instance to un-allocate stock against 

818 output: Specify a particular StockItem (output) to un-allocate stock against 

819 """ 

820 allocations = self.allocated_stock.filter(install_into=output) 

821 

822 if build_line: 

823 allocations = allocations.filter(build_line=build_line) 

824 

825 allocations.delete() 

826 

827 @transaction.atomic 

828 def create_build_output(self, quantity, **kwargs) -> QuerySet: 

829 """Create a new build output against this BuildOrder. 

830 

831 Arguments: 

832 quantity: The quantity of the item to produce 

833 

834 Kwargs: 

835 batch: Override batch code 

836 serials: Serial numbers 

837 location: Override location 

838 auto_allocate: Automatically allocate stock with matching serial numbers 

839 

840 Returns: 

841 A QuerySet of the created output (StockItem) objects. 

842 """ 

843 trackable_parts = self.part.get_trackable_parts() 

844 

845 # Create (and cache) a map of valid parts for allocation 

846 valid_parts = {} 

847 

848 for bom_item in trackable_parts: 

849 parts = bom_item.get_valid_parts_for_allocation() 

850 valid_parts[bom_item.pk] = [part.pk for part in parts] 

851 

852 user = kwargs.get('user') 

853 batch = kwargs.get('batch', self.batch) 

854 location = kwargs.get('location') 

855 serials = kwargs.get('serials') 

856 auto_allocate = kwargs.get('auto_allocate', False) 

857 

858 if location is None: 

859 location = self.destination or self.part.get_default_location() 

860 

861 if self.part.has_trackable_parts and not serials: 

862 raise ValidationError({ 

863 'serials': _('Serial numbers must be provided for trackable parts') 

864 }) 

865 

866 outputs = [] 

867 

868 # We are generating multiple serialized outputs 

869 if serials: 

870 """Create multiple build outputs with a single quantity of 1.""" 

871 

872 # Create tracking entries for each item 

873 tracking = [] 

874 allocations = [] 

875 

876 outputs = stock.models.StockItem._create_serial_numbers( 

877 serials, 

878 part=self.part, 

879 build=self, 

880 batch=batch, 

881 location=location, 

882 is_building=True, 

883 ) 

884 

885 for output in outputs: 

886 # Generate a new historical tracking entry 

887 if entry := output.add_tracking_entry( 

888 StockHistoryCode.BUILD_OUTPUT_CREATED, 

889 user, 

890 deltas={ 

891 'quantity': 1, 

892 'buildorder': self.pk, 

893 'batch': output.batch, 

894 'serial': output.serial, 

895 'location': location.pk if location else None, 

896 }, 

897 commit=False, 

898 ): 

899 tracking.append(entry) 

900 

901 # Auto-allocate stock based on serial number 

902 if auto_allocate: 

903 if new_allocations := self.auto_allocate_tracked_output( 

904 output, location=self.take_from 

905 ): 

906 allocations.extend(new_allocations) 

907 

908 # Bulk create tracking entries 

909 stock.models.StockItemTracking.objects.bulk_create(tracking, batch_size=250) 

910 

911 # Generate stock allocations 

912 BuildItem.objects.bulk_create(allocations, batch_size=250) 

913 

914 else: 

915 """Create a single build output of the given quantity.""" 

916 

917 output = stock.models.StockItem.objects.create( 

918 quantity=quantity, 

919 location=location, 

920 part=self.part, 

921 build=self, 

922 batch=batch, 

923 is_building=True, 

924 ) 

925 

926 output.add_tracking_entry( 

927 StockHistoryCode.BUILD_OUTPUT_CREATED, 

928 user, 

929 deltas={ 

930 'quantity': float(quantity), 

931 'buildorder': self.pk, 

932 'batch': batch, 

933 'location': location.pk if location else None, 

934 }, 

935 ) 

936 

937 # Ensure we return a QuerySet object here, too 

938 outputs = stock.models.StockItem.objects.filter(pk=output.pk) 

939 

940 if self.status == BuildStatus.PENDING: 

941 self.status = BuildStatus.PRODUCTION.value 

942 self.save() 

943 

944 return outputs 

945 

946 @transaction.atomic 

947 def delete_output(self, output): 

948 """Remove a build output from the database. 

949 

950 Executes: 

951 - Deallocate any build items against the output 

952 - Delete the output StockItem 

953 """ 

954 if not output: 

955 raise ValidationError(_('No build output specified')) 

956 

957 if not output.is_building: 

958 raise ValidationError(_('Build output is already completed')) 

959 

960 if output.build != self: 

961 raise ValidationError(_('Build output does not match Build Order')) 

962 

963 # Deallocate all build items against the output 

964 self.deallocate_stock(output=output) 

965 

966 # Remove the build output from the database 

967 # This is a special case where serialized stock can be deleted, 

968 # independent of the global setting which normally prevents deletion of serialized stock items 

969 output.delete(ignore_serial_check=True) 

970 

971 @transaction.atomic 

972 def trim_allocated_stock(self): 

973 """Called after save to reduce allocated stock if the build order is now overallocated.""" 

974 # Only need to worry about untracked stock here 

975 

976 items_to_save = [] 

977 items_to_delete = [] 

978 

979 lines = self.untracked_line_items.all() 

980 lines = lines.exclude(bom_item__consumable=True) 

981 lines = lines.annotate(allocated=annotate_allocated_quantity()) 

982 

983 for build_line in lines: 

984 reduce_by = build_line.allocated - build_line.quantity 

985 

986 if reduce_by <= 0: 

987 continue 

988 

989 # Find BuildItem objects to trim 

990 for item in BuildItem.objects.filter(build_line=build_line): 

991 # Previous item completed the job 

992 if reduce_by <= 0: 

993 break 

994 

995 # Easy case - this item can just be reduced. 

996 if item.quantity > reduce_by: 

997 item.quantity -= reduce_by 

998 items_to_save.append(item) 

999 break 

1000 

1001 # Harder case, this item needs to be deleted, and any remainder 

1002 # taken from the next items in the list. 

1003 reduce_by -= item.quantity 

1004 items_to_delete.append(item) 

1005 

1006 # Save the updated BuildItem objects 

1007 BuildItem.objects.bulk_update(items_to_save, ['quantity']) 

1008 

1009 # Delete the remaining BuildItem objects 

1010 BuildItem.objects.filter(pk__in=[item.pk for item in items_to_delete]).delete() 

1011 

1012 @property 

1013 def allocated_stock(self) -> QuerySet: 

1014 """Returns a QuerySet object of all BuildItem objects which point back to this Build.""" 

1015 return BuildItem.objects.filter(build_line__build=self) 

1016 

1017 def subtract_allocated_stock(self, user) -> None: 

1018 """Removes the allocated untracked items from stock.""" 

1019 # Find all BuildItem objects which point to this build 

1020 items = self.allocated_stock.filter( 

1021 build_line__bom_item__sub_part__trackable=False 

1022 ) 

1023 

1024 # Remove stock 

1025 for item in items: 

1026 item.complete_allocation(user=user) 

1027 

1028 # Delete allocation 

1029 items.all().delete() 

1030 

1031 @transaction.atomic 

1032 def scrap_build_output( 

1033 self, output: stock.models.StockItem, quantity, location, **kwargs 

1034 ): 

1035 """Mark a particular build output as scrapped / rejected. 

1036 

1037 - Mark the output as "complete" 

1038 - *Do Not* update the "completed" count for this order 

1039 - Set the item status to "scrapped" 

1040 - Add a transaction entry to the stock item history 

1041 """ 

1042 if not output: 

1043 raise ValidationError(_('No build output specified')) 

1044 

1045 # If quantity is not specified, assume the entire output quantity 

1046 if quantity is None: 

1047 quantity = output.quantity 

1048 

1049 if quantity <= 0: 

1050 raise ValidationError({'quantity': _('Quantity must be greater than zero')}) 

1051 

1052 if quantity > output.quantity: 

1053 raise ValidationError({ 

1054 'quantity': _('Quantity cannot be greater than the output quantity') 

1055 }) 

1056 

1057 user = kwargs.get('user') 

1058 notes = kwargs.get('notes', '') 

1059 discard_allocations = kwargs.get('discard_allocations', False) 

1060 

1061 if quantity < output.quantity: 

1062 # Split output into two items 

1063 output = output.splitStock( 

1064 quantity, location=location, user=user, allow_production=True 

1065 ) 

1066 output.build = self 

1067 

1068 # Update build output item 

1069 output.is_building = False 

1070 output.status = StockStatus.REJECTED.value 

1071 output.location = location 

1072 output.save(add_note=False) 

1073 

1074 allocated_items = output.items_to_install.all() 

1075 

1076 # Complete or discard allocations 

1077 for build_item in allocated_items: 

1078 if not discard_allocations: 

1079 build_item.complete_allocation(user=user) 

1080 

1081 # Delete allocations 

1082 allocated_items.delete() 

1083 

1084 output.add_tracking_entry( 

1085 StockHistoryCode.BUILD_OUTPUT_REJECTED, 

1086 user, 

1087 notes=notes, 

1088 deltas={ 

1089 'quantity': float(quantity), 

1090 'location': location.pk, 

1091 'status': StockStatus.REJECTED.value, 

1092 'buildorder': self.pk, 

1093 }, 

1094 ) 

1095 

1096 @transaction.atomic 

1097 def complete_build_output( 

1098 self, 

1099 output: stock.models.StockItem, 

1100 user: User, 

1101 quantity: Optional[decimal.Decimal] = None, 

1102 **kwargs, 

1103 ): 

1104 """Complete a particular build output. 

1105 

1106 Arguments: 

1107 output: The StockItem instance (build output) to complete 

1108 user: The user who is completing the build output 

1109 quantity: The quantity to complete (defaults to entire output quantity) 

1110 

1111 Notes: 

1112 - Remove allocated StockItems 

1113 - Mark the output as complete 

1114 """ 

1115 # Select the location for the build output 

1116 location = kwargs.get('location', self.destination) 

1117 status = kwargs.get('status', StockStatus.OK.value) 

1118 notes = kwargs.get('notes', '') 

1119 

1120 required_tests = kwargs.get('required_tests', output.part.getRequiredTests()) 

1121 prevent_on_incomplete = kwargs.get( 

1122 'prevent_on_incomplete', 

1123 prevent_build_output_complete_on_incompleted_tests(), 

1124 ) 

1125 

1126 if prevent_on_incomplete and not output.passedAllRequiredTests( 

1127 required_tests=required_tests 

1128 ): 

1129 msg = _('Build output has not passed all required tests') 

1130 

1131 if serial := output.serial: 

1132 msg = _(f'Build output {serial} has not passed all required tests') 

1133 

1134 raise ValidationError(msg) 

1135 

1136 # List the allocated BuildItem objects for the given output 

1137 allocated_items = output.items_to_install.all() 

1138 

1139 # Ensure that none of the allocated items are themselves still "in production" 

1140 for build_item in allocated_items: 

1141 if build_item.stock_item.is_building: 

1142 raise ValidationError( 

1143 _('Allocated stock items are still in production') 

1144 ) 

1145 

1146 # If a partial quantity is provided, split the stock output 

1147 if quantity is not None and quantity != output.quantity: 

1148 # Cannot split a build output with allocated items 

1149 if allocated_items.count() > 0: 

1150 raise ValidationError( 

1151 _('Cannot partially complete a build output with allocated items') 

1152 ) 

1153 

1154 if quantity <= 0: 

1155 raise ValidationError({ 

1156 'quantity': _('Quantity must be greater than zero') 

1157 }) 

1158 

1159 if quantity > output.quantity: 

1160 raise ValidationError({ 

1161 'quantity': _('Quantity cannot be greater than the output quantity') 

1162 }) 

1163 

1164 # Split the stock item 

1165 output = output.splitStock(quantity, user=user, allow_production=True) 

1166 

1167 for build_item in allocated_items: 

1168 # Complete the allocation of stock for that item 

1169 build_item.complete_allocation(user=user) 

1170 

1171 # Delete the BuildItem objects from the database 

1172 allocated_items.all().delete() 

1173 

1174 # Ensure that the output is updated correctly 

1175 output.build = self 

1176 output.is_building = False 

1177 output.location = location 

1178 

1179 # Assign the stock status 

1180 output.set_status(status) 

1181 

1182 output.save(add_note=False) 

1183 

1184 deltas = { 

1185 'status': status, 

1186 'buildorder': self.pk, 

1187 'quantity': float(output.quantity), 

1188 } 

1189 

1190 if location: 

1191 deltas['location'] = location.pk 

1192 

1193 output.add_tracking_entry( 

1194 StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas=deltas 

1195 ) 

1196 

1197 trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk) 

1198 

1199 # Increase the completed quantity for this build 

1200 # Increment at the database level to prevent lost updates 

1201 # (multiple outputs may be completed concurrently) 

1202 self.completed = F('completed') + output.quantity 

1203 self.save(update_fields=['completed']) 

1204 self.refresh_from_db(fields=['completed']) 

1205 

1206 @transaction.atomic 

1207 def auto_allocate_stock( 

1208 self, item_type: str = BuildItemTypes.UNTRACKED, **kwargs 

1209 ) -> None: 

1210 """Automatically allocate stock items against this build order. 

1211 

1212 Arguments: 

1213 item_type: The type of BuildItem to allocate (default = untracked) 

1214 """ 

1215 if item_type in [self.BuildItemTypes.UNTRACKED, self.BuildItemTypes.ALL]: 

1216 self.auto_allocate_untracked_stock(**kwargs) 

1217 

1218 if item_type in [self.BuildItemTypes.TRACKED, self.BuildItemTypes.ALL]: 

1219 self.auto_allocate_tracked_stock(**kwargs) 

1220 

1221 def auto_allocate_tracked_output(self, output, **kwargs): 

1222 """Auto-allocate tracked stock items against a particular build output. 

1223 

1224 This may occur at the time of build output creation, or later when triggered manually. 

1225 """ 

1226 location = kwargs.get('location') 

1227 exclude_location = kwargs.get('exclude_location') 

1228 substitutes = kwargs.get('substitutes', True) 

1229 optional_items = kwargs.get('optional_items', False) 

1230 

1231 # Newly created allocations (not yet committed to the database) 

1232 allocations = [] 

1233 

1234 # Return early if the output should not be auto-allocated 

1235 if not output.serialized: 

1236 return allocations 

1237 

1238 tracked_line_items = self.tracked_line_items.filter( 

1239 bom_item__consumable=False, bom_item__sub_part__virtual=False 

1240 ) 

1241 

1242 for line_item in tracked_line_items: 

1243 bom_item = line_item.bom_item 

1244 

1245 if bom_item.consumable: 

1246 # Do not auto-allocate stock to consumable BOM items 

1247 continue 

1248 

1249 if bom_item.optional and not optional_items: 

1250 # User has specified that optional_items are to be ignored 

1251 continue 

1252 

1253 # If the line item is already fully allocated, we can continue 

1254 if line_item.is_fully_allocated(): 

1255 continue 

1256 

1257 # If there is already allocated stock against this build output, skip it 

1258 if line_item.allocated_quantity(output=output) > 0: 

1259 continue 

1260 

1261 # Find available parts (may include variants and substitutes) 

1262 available_parts = bom_item.get_valid_parts_for_allocation( 

1263 allow_variants=True, allow_substitutes=substitutes 

1264 ) 

1265 

1266 # Find stock items which match the output serial number 

1267 available_stock = stock.models.StockItem.objects.filter( 

1268 part__in=list(available_parts), 

1269 part__active=True, 

1270 part__virtual=False, 

1271 serial=output.serial, 

1272 ).exclude(Q(serial=None) | Q(serial='')) 

1273 

1274 if location: 

1275 # Filter only stock items located "below" the specified location 

1276 sublocations = location.get_descendants(include_self=True) 

1277 available_stock = available_stock.filter( 

1278 location__in=list(sublocations) 

1279 ) 

1280 

1281 if exclude_location: 

1282 # Exclude any stock items from the provided location 

1283 sublocations = exclude_location.get_descendants(include_self=True) 

1284 available_stock = available_stock.exclude( 

1285 location__in=list(sublocations) 

1286 ) 

1287 

1288 # Filter stock items to only those which are in stock 

1289 # Note that we can accept "in production" items here 

1290 available_items = list( 

1291 filter( 

1292 lambda item: item.is_in_stock(check_in_production=False), 

1293 available_stock, 

1294 ) 

1295 ) 

1296 

1297 if len(available_items) == 1: 

1298 allocations.append( 

1299 BuildItem( 

1300 build_line=line_item, 

1301 stock_item=available_items[0], 

1302 quantity=1, 

1303 install_into=output, 

1304 ) 

1305 ) 

1306 

1307 return allocations 

1308 

1309 def auto_allocate_tracked_stock(self, **kwargs): 

1310 """Automatically allocate tracked stock items against serialized build outputs. 

1311 

1312 This function allocates tracked stock items automatically against serialized build outputs, 

1313 following a set of "guidelines": 

1314 

1315 - Only "tracked" BOM items are considered (untracked BOM items must be allocated separately) 

1316 - Only build outputs with serial numbers are considered 

1317 - Unallocated tracked components are allocated against build outputs with matching serial numbers 

1318 """ 

1319 new_items = [] 

1320 

1321 # Select only "tracked" line items 

1322 for output in self.incomplete_outputs.all(): 

1323 new_items.extend(self.auto_allocate_tracked_output(output, **kwargs)) 

1324 

1325 # Bulk-create the new BuildItem objects 

1326 BuildItem.objects.bulk_create(new_items, batch_size=250) 

1327 

1328 def auto_allocate_untracked_stock(self, **kwargs): 

1329 """Automatically allocate untracked stock items against this build order. 

1330 

1331 This function allocates untracked stock items automatically against a BuildOrder, 

1332 following a set of "guidelines": 

1333 

1334 - Only "untracked" BOM items are considered (tracked BOM items must be allocated separately) 

1335 - If a particular BOM item is already fully allocated, it is skipped 

1336 - Extract all available stock items for the BOM part 

1337 - If variant stock is allowed, extract stock for those too 

1338 - If substitute parts are available, extract stock for those also 

1339 - If a single stock item is found, we can allocate that and move on! 

1340 - If multiple stock items are found, we *may* be able to allocate: 

1341 - If the calling function has specified that items are interchangeable 

1342 """ 

1343 location = kwargs.get('location') 

1344 exclude_location = kwargs.get('exclude_location') 

1345 interchangeable = kwargs.get('interchangeable', False) 

1346 substitutes = kwargs.get('substitutes', True) 

1347 optional_items = kwargs.get('optional_items', False) 

1348 stock_sort_by = kwargs.get('stock_sort_by', stock.models.STOCK_SORT_DEFAULT) 

1349 line_ids = kwargs.get('line_ids') 

1350 

1351 def stock_sort(item, bom_item, variant_parts): 

1352 if item.part == bom_item.sub_part: 

1353 return 1 

1354 elif item.part in variant_parts: 

1355 return 2 

1356 return 3 

1357 

1358 new_items = [] 

1359 

1360 # Select only "untracked" line items 

1361 untracked_lines = self.untracked_line_items.all() 

1362 if line_ids: 

1363 untracked_lines = untracked_lines.filter(pk__in=line_ids) 

1364 

1365 for line_item in untracked_lines: 

1366 # Find the referenced BomItem 

1367 bom_item = line_item.bom_item 

1368 

1369 if bom_item.consumable: 

1370 # Do not auto-allocate stock to consumable BOM items 

1371 continue 

1372 

1373 if bom_item.optional and not optional_items: 

1374 # User has specified that optional_items are to be ignored 

1375 continue 

1376 

1377 variant_parts = bom_item.sub_part.get_descendants(include_self=False) 

1378 

1379 unallocated_quantity = line_item.unallocated_quantity() 

1380 

1381 if unallocated_quantity <= 0: 

1382 # This BomItem is fully allocated, we can continue 

1383 continue 

1384 

1385 # Check which parts we can "use" (may include variants and substitutes) 

1386 available_parts = bom_item.get_valid_parts_for_allocation( 

1387 allow_variants=True, allow_inactive=False, allow_substitutes=substitutes 

1388 ) 

1389 

1390 # Look for available stock items 

1391 available_stock = stock.models.StockItem.objects.filter( 

1392 stock.models.StockItem.IN_STOCK_FILTER 

1393 ) 

1394 

1395 # Filter by list of available parts 

1396 available_stock = available_stock.filter(part__in=list(available_parts)) 

1397 

1398 # Ensure part is active and not virtual 

1399 available_stock = available_stock.filter( 

1400 part__active=True, part__virtual=False 

1401 ) 

1402 

1403 # Filter out "serialized" stock items, these cannot be auto-allocated 

1404 available_stock = available_stock.filter( 

1405 Q(serial=None) | Q(serial='') 

1406 ).distinct() 

1407 

1408 if location: 

1409 # Filter only stock items located "below" the specified location 

1410 sublocations = location.get_descendants(include_self=True) 

1411 available_stock = available_stock.filter( 

1412 location__in=list(sublocations) 

1413 ) 

1414 

1415 if exclude_location: 

1416 # Exclude any stock items from the provided location 

1417 sublocations = exclude_location.get_descendants(include_self=True) 

1418 available_stock = available_stock.exclude( 

1419 location__in=list(sublocations) 

1420 ) 

1421 

1422 # Apply secondary ORM ordering before the Python match-quality stable-sort. 

1423 if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST: 

1424 available_stock = available_stock.order_by( 

1425 F('expiry_date').asc(nulls_last=True) 

1426 ) 

1427 else: 

1428 available_stock = available_stock.order_by(stock_sort_by) 

1429 

1430 """ 

1431 Next, we sort the available stock items with the following priority: 

1432 1. Direct part matches (+1) 

1433 2. Variant part matches (+2) 

1434 3. Substitute part matches (+3) 

1435 

1436 This ensures that allocation priority is first given to "direct" parts. 

1437 Python's stable sort preserves the secondary ORM ordering within each group. 

1438 """ 

1439 available_stock = sorted( 

1440 available_stock, 

1441 key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v), 

1442 ) 

1443 

1444 if len(available_stock) == 1 or interchangeable: 

1445 # Either there is only a single stock item available, 

1446 # or all items are "interchangeable" and we don't care where we take stock from 

1447 

1448 for stock_item in available_stock: 

1449 # Skip inactive parts 

1450 if not stock_item.part.active: 

1451 continue 

1452 

1453 # How much of the stock item is "available" for allocation? 

1454 quantity = min( 

1455 unallocated_quantity, stock_item.unallocated_quantity() 

1456 ) 

1457 

1458 if quantity > 0: 

1459 try: 

1460 new_items.append( 

1461 BuildItem( 

1462 build_line=line_item, 

1463 stock_item=stock_item, 

1464 quantity=quantity, 

1465 ) 

1466 ) 

1467 

1468 # Subtract the required quantity 

1469 unallocated_quantity -= quantity 

1470 

1471 except (ValidationError, serializers.ValidationError) as exc: 

1472 # Re-raise with a Django-compatible validation payload 

1473 raise ValidationError( 

1474 serializers.as_serializer_error(exc) 

1475 ) from exc 

1476 

1477 if unallocated_quantity <= 0: 

1478 # We have now fully-allocated this BomItem - no need to continue! 

1479 break 

1480 

1481 # Bulk-create the new BuildItem objects 

1482 BuildItem.objects.bulk_create(new_items, batch_size=250) 

1483 

1484 def unallocated_lines(self, tracked: Optional[bool] = None) -> QuerySet: 

1485 """Returns a list of BuildLine objects which have not been fully allocated.""" 

1486 lines = self.build_lines.all() 

1487 

1488 # Remove any 'consumable' line items 

1489 lines = lines.exclude(bom_item__consumable=True) 

1490 

1491 if tracked is True: 

1492 lines = lines.filter(bom_item__sub_part__trackable=True) 

1493 elif tracked is False: 

1494 lines = lines.filter(bom_item__sub_part__trackable=False) 

1495 

1496 lines = lines.prefetch_related('allocations') 

1497 

1498 lines = lines.annotate( 

1499 allocated=annotate_allocated_quantity(), 

1500 required=annotate_required_quantity(), 

1501 ).filter(allocated__lt=F('required')) 

1502 

1503 return lines 

1504 

1505 def is_fully_allocated(self, tracked: Optional[bool] = None) -> bool: 

1506 """Test if the BuildOrder has been fully allocated. 

1507 

1508 Arguments: 

1509 tracked: If True, only consider tracked BuildLine items. If False, only consider untracked BuildLine items. 

1510 

1511 Returns: 

1512 True if the BuildOrder has been fully allocated, otherwise False 

1513 """ 

1514 return self.unallocated_lines(tracked=tracked).count() == 0 

1515 

1516 def is_output_fully_allocated(self, output) -> bool: 

1517 """Determine if the specified output (StockItem) has been fully allocated for this build. 

1518 

1519 Arguments: 

1520 output: StockItem object (the "in production" output to test against) 

1521 

1522 To determine if the output has been fully allocated, 

1523 we need to test all "trackable" BuildLine objects 

1524 """ 

1525 lines = self.build_lines.filter(bom_item__sub_part__trackable=True) 

1526 lines = lines.exclude(bom_item__consumable=True) 

1527 

1528 # Find any lines which have not been fully allocated 

1529 for line in lines: 

1530 # Grab all BuildItem objects which point to this output 

1531 allocations = BuildItem.objects.filter(build_line=line, install_into=output) 

1532 

1533 allocated = allocations.aggregate( 

1534 q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField()) 

1535 ) 

1536 

1537 # The amount allocated against an output must at least equal the BOM quantity 

1538 if allocated['q'] < line.bom_item.quantity: 

1539 return False 

1540 

1541 # At this stage, we can assume that the output is fully allocated 

1542 return True 

1543 

1544 def is_overallocated(self) -> bool: 

1545 """Test if the BuildOrder has been over-allocated. 

1546 

1547 Returns: 

1548 True if any BuildLine has been over-allocated. 

1549 """ 

1550 lines = self.build_lines.all().exclude(bom_item__consumable=True) 

1551 

1552 lines = lines.prefetch_related('allocations') 

1553 

1554 # Find any lines which have been over-allocated 

1555 lines = lines.annotate( 

1556 allocated=annotate_allocated_quantity(), 

1557 required=annotate_required_quantity(), 

1558 ).filter(allocated__gt=F('required')) 

1559 

1560 return lines.count() > 0 

1561 

1562 @property 

1563 def is_active(self) -> bool: 

1564 """Is this build active? 

1565 

1566 An active build is either: 

1567 - PENDING 

1568 - HOLDING 

1569 """ 

1570 return self.status in BuildStatusGroups.ACTIVE_CODES 

1571 

1572 @property 

1573 def is_complete(self) -> bool: 

1574 """Returns True if the build status is COMPLETE.""" 

1575 return self.status == BuildStatus.COMPLETE.value 

1576 

1577 @transaction.atomic 

1578 def create_build_line_items(self, prevent_duplicates: bool = True) -> None: 

1579 """Create BuildLine objects for each BOM line in this BuildOrder.""" 

1580 lines = [] 

1581 

1582 # Find all non-virtual BOM items for the parent part 

1583 bom_items = self.part.get_bom_items(include_virtual=False) 

1584 

1585 logger.info( 

1586 'Creating BuildLine objects for BuildOrder %s (%s items)', 

1587 self.pk, 

1588 len(bom_items), 

1589 ) 

1590 

1591 # Iterate through each part required to build the parent part 

1592 for bom_item in bom_items: 

1593 if prevent_duplicates: 

1594 if BuildLine.objects.filter(build=self, bom_item=bom_item).exists(): 

1595 logger.info( 

1596 'BuildLine already exists for BuildOrder %s and BomItem %s', 

1597 self.pk, 

1598 bom_item.pk, 

1599 ) 

1600 continue 

1601 

1602 # Calculate required quantity 

1603 quantity = bom_item.get_required_quantity(self.quantity) 

1604 

1605 lines.append(BuildLine(build=self, bom_item=bom_item, quantity=quantity)) 

1606 

1607 BuildLine.objects.bulk_create(lines, batch_size=250) 

1608 

1609 if len(lines) > 0: 

1610 logger.info('Created %s BuildLine objects for BuildOrder', len(lines)) 

1611 

1612 @transaction.atomic 

1613 def update_build_line_items(self) -> None: 

1614 """Rebuild required quantity field for each BuildLine object.""" 

1615 lines_to_update = [] 

1616 

1617 for line in self.build_lines.all(): 

1618 line.quantity = line.bom_item.get_required_quantity(self.quantity) 

1619 lines_to_update.append(line) 

1620 

1621 BuildLine.objects.bulk_update(lines_to_update, ['quantity']) 

1622 

1623 logger.info('Updated %s BuildLine objects for BuildOrder', len(lines_to_update)) 

1624 

1625 

1626@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') 

1627def after_save_build(sender, instance: Build, created: bool, **kwargs): 

1628 """Callback function to be executed after a Build instance is saved.""" 

1629 # Escape if we are importing data 

1630 if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase( 

1631 allow_test=True 

1632 ): 

1633 return 

1634 

1635 if instance: 

1636 if created: 

1637 # A new Build has just been created 

1638 

1639 # Generate initial BuildLine objects for the Build 

1640 instance.create_build_line_items() 

1641 

1642 # Notify the responsible users that the build order has been created 

1643 InvenTree.helpers_model.notify_responsible( 

1644 instance, 

1645 sender, 

1646 exclude=instance.issued_by, 

1647 extra_users=instance.part.get_subscribers(), 

1648 ) 

1649 

1650 else: 

1651 # Update BuildLine objects if the Build quantity has changed 

1652 instance.update_build_line_items() 

1653 

1654 

1655class BuildLineReportContext(report.mixins.BaseReportContext, TypedDict): 

1656 """Context for the BuildLine model. 

1657 

1658 Attributes: 

1659 allocated_quantity: The quantity of the part which has been allocated to this build 

1660 allocations: A query set of all StockItem objects which have been allocated to this build line 

1661 bom_item: The BomItem associated with this line item 

1662 build: The BuildOrder instance associated with this line item 

1663 build_line: The build line instance itself 

1664 part: The sub-part (component) associated with the linked BomItem instance 

1665 quantity: The quantity required for this line item 

1666 """ 

1667 

1668 allocated_quantity: decimal.Decimal 

1669 allocations: report.mixins.QuerySet['BuildItem'] 

1670 bom_item: part.models.BomItem 

1671 build: Build 

1672 build_line: 'BuildLine' 

1673 part: part.models.Part 

1674 quantity: decimal.Decimal 

1675 

1676 

1677class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel): 

1678 """A BuildLine object links a BOMItem to a Build. 

1679 

1680 When a new Build is created, the BuildLine objects are created automatically. 

1681 - A BuildLine entry is created for each BOM item associated with the part 

1682 - The quantity is set to the quantity required to build the part 

1683 - BuildItem objects are associated with a particular BuildLine 

1684 

1685 Once a build has been created, BuildLines can (optionally) be removed from the Build 

1686 

1687 Attributes: 

1688 build: Link to a Build object 

1689 bom_item: Link to a BomItem object 

1690 quantity: Number of units required for the Build 

1691 consumed: Number of units which have been consumed against this line item 

1692 """ 

1693 

1694 class Meta: 

1695 """Model meta options.""" 

1696 

1697 verbose_name = _('Build Order Line Item') 

1698 unique_together = [('build', 'bom_item')] 

1699 

1700 @staticmethod 

1701 def get_api_url(): 

1702 """Return the API URL used to access this model.""" 

1703 return reverse('api-build-line-list') 

1704 

1705 # type 

1706 def report_context(self) -> BuildLineReportContext: 

1707 """Generate custom report context for this BuildLine object.""" 

1708 return { 

1709 'allocated_quantity': self.allocated_quantity, 

1710 'allocations': self.allocations, 

1711 'bom_item': self.bom_item, 

1712 'build': self.build, 

1713 'build_line': self, 

1714 'part': self.bom_item.sub_part, 

1715 'quantity': self.quantity, 

1716 } 

1717 

1718 build = models.ForeignKey( 

1719 Build, 

1720 on_delete=models.CASCADE, 

1721 related_name='build_lines', 

1722 help_text=_('Build object'), 

1723 ) 

1724 

1725 bom_item = models.ForeignKey( 

1726 part.models.BomItem, on_delete=models.CASCADE, related_name='build_lines' 

1727 ) 

1728 

1729 quantity = models.DecimalField( 

1730 decimal_places=5, 

1731 max_digits=15, 

1732 default=1, 

1733 validators=[MinValueValidator(0)], 

1734 verbose_name=_('Quantity'), 

1735 help_text=_('Required quantity for build order'), 

1736 ) 

1737 

1738 consumed = models.DecimalField( 

1739 decimal_places=5, 

1740 max_digits=15, 

1741 default=0, 

1742 validators=[MinValueValidator(0)], 

1743 verbose_name=_('Consumed'), 

1744 help_text=_('Quantity of consumed stock'), 

1745 ) 

1746 

1747 @property 

1748 def part(self): 

1749 """Return the sub_part reference from the link bom_item.""" 

1750 return self.bom_item.sub_part 

1751 

1752 def allocated_quantity(self, output: Optional[stock.models.StockItem] = None): 

1753 """Calculate the total allocated quantity for this BuildLine.""" 

1754 # Queryset containing all BuildItem objects allocated against this BuildLine 

1755 allocations = self.allocations.all() 

1756 

1757 if output is not None: 

1758 allocations = allocations.filter(install_into=output) 

1759 

1760 allocated = allocations.aggregate( 

1761 q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField()) 

1762 ) 

1763 

1764 return allocated['q'] 

1765 

1766 def unallocated_quantity(self): 

1767 """Return the unallocated quantity for this BuildLine. 

1768 

1769 - Start with the required quantity 

1770 - Subtract the consumed quantity 

1771 - Subtract the allocated quantity 

1772 

1773 Return the remaining quantity (or zero if negative) 

1774 """ 

1775 return max(self.quantity - self.consumed - self.allocated_quantity(), 0) 

1776 

1777 def is_fully_allocated(self) -> bool: 

1778 """Return True if this BuildLine is fully allocated.""" 

1779 if self.bom_item.consumable: 

1780 return True 

1781 

1782 required = max(0, self.quantity - self.consumed) 

1783 

1784 return self.allocated_quantity() >= required 

1785 

1786 def is_overallocated(self): 

1787 """Return True if this BuildLine is over-allocated.""" 

1788 required = max(0, self.quantity - self.consumed) 

1789 

1790 return self.allocated_quantity() > required 

1791 

1792 def is_fully_consumed(self) -> bool: 

1793 """Return True if this BuildLine is fully consumed.""" 

1794 return self.consumed >= self.quantity 

1795 

1796 

1797class BuildItem(InvenTree.models.InvenTreeMetadataModel): 

1798 """A BuildItem links multiple StockItem objects to a Build. 

1799 

1800 These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed. 

1801 

1802 Attributes: 

1803 build: Link to a Build object 

1804 build_line: Link to a BuildLine object (this is a "line item" within a build) 

1805 stock_item: Link to a StockItem object 

1806 quantity: Number of units allocated 

1807 install_into: Destination stock item (or None) 

1808 """ 

1809 

1810 class Meta: 

1811 """Model meta options.""" 

1812 

1813 unique_together = [('build_line', 'stock_item', 'install_into')] 

1814 

1815 @staticmethod 

1816 def get_api_url(): 

1817 """Return the API URL used to access this model.""" 

1818 return reverse('api-build-item-list') 

1819 

1820 def save(self, *args, **kwargs): 

1821 """Custom save method for the BuildItem model.""" 

1822 self.clean(raise_error=False) 

1823 super().save() 

1824 

1825 def clean(self, raise_error: bool = True): 

1826 """Check validity of this BuildItem instance. 

1827 

1828 The following checks are performed: 

1829 - StockItem.part must be in the BOM of the Part object referenced by Build 

1830 - Allocation quantity cannot exceed available quantity 

1831 """ 

1832 self.validate_unique() 

1833 

1834 super().clean() 

1835 

1836 try: 

1837 # If the 'part' is trackable, then the 'install_into' field must be set! 

1838 if ( 

1839 self.stock_item.part 

1840 and self.stock_item.part.trackable 

1841 and not self.install_into 

1842 ): 

1843 raise ValidationError( 

1844 _( 

1845 'Build item must specify a build output, as master part is marked as trackable' 

1846 ) 

1847 ) 

1848 

1849 self.check_allocated_quantity(raise_error=raise_error) 

1850 

1851 except stock.models.StockItem.DoesNotExist: 

1852 raise ValidationError('Stock item must be specified') 

1853 except part.models.Part.DoesNotExist: 

1854 raise ValidationError('Part must be specified') 

1855 

1856 """ 

1857 Attempt to find the "BomItem" which links this BuildItem to the build. 

1858 

1859 - If a BomItem is already set, and it is valid, then we are ok! 

1860 """ 

1861 

1862 valid = False 

1863 

1864 if self.bom_item and self.build: 

1865 """ 

1866 A BomItem object has already been assigned. This is valid if: 

1867 

1868 a) It points to the same "part" as the referenced build 

1869 b) Either: 

1870 i) The sub_part points to the same part as the referenced StockItem 

1871 ii) The BomItem allows variants and the part referenced by the StockItem 

1872 is a variant of the sub_part referenced by the BomItem 

1873 iii) The Part referenced by the StockItem is a valid substitute for the BomItem 

1874 """ 

1875 

1876 if self.build.part == self.bom_item.part: 

1877 valid = self.bom_item.is_stock_item_valid(self.stock_item) 

1878 

1879 elif self.bom_item.inherited: 

1880 if self.build.part in self.bom_item.part.get_descendants( 

1881 include_self=False 

1882 ): 

1883 valid = self.bom_item.is_stock_item_valid(self.stock_item) 

1884 

1885 # If the existing BomItem is *not* valid, try to find a match 

1886 if not valid and self.build and self.stock_item: 

1887 ancestors = self.stock_item.part.get_ancestors( 

1888 include_self=True, ascending=True 

1889 ) 

1890 

1891 for idx, ancestor in enumerate(ancestors): 

1892 build_line = BuildLine.objects.filter( 

1893 build=self.build, bom_item__part=ancestor 

1894 ) 

1895 

1896 if build_line.exists(): 

1897 line = build_line.first() 

1898 

1899 if idx == 0 or line.bom_item.allow_variants: 

1900 valid = True 

1901 self.build_line = line 

1902 break 

1903 

1904 # BomItem did not exist or could not be validated. 

1905 # Search for a new one 

1906 if not valid: 

1907 raise ValidationError({ 

1908 'stock_item': _('Selected stock item does not match BOM line') 

1909 }) 

1910 

1911 def check_allocated_quantity(self, raise_error: bool = False): 

1912 """Ensure that the allocated quantity is valid. 

1913 

1914 Will reduce the allocated quantity if it exceeds available stock. 

1915 

1916 Arguments: 

1917 raise_error: If True, raise ValidationError on failure 

1918 

1919 Raises: 

1920 ValidationError: If the allocated quantity is invalid and raise_error is True 

1921 """ 

1922 error = None 

1923 

1924 # Allocated quantity must be positive 

1925 if self.quantity <= 0: 

1926 self.quantity = 0 

1927 error = {'quantity': _('Allocated quantity must be greater than zero')} 

1928 

1929 # Quantity must be 1 for serialized stock 

1930 if self.stock_item.serialized and self.quantity != 1: 

1931 self.quantity = 1 

1932 raise ValidationError({ 

1933 'quantity': _('Quantity must be 1 for serialized stock') 

1934 }) 

1935 

1936 # Allocated quantity cannot exceed available stock quantity 

1937 if self.quantity > self.stock_item.quantity: 

1938 q = InvenTree.helpers.normalize(self.quantity) 

1939 a = InvenTree.helpers.normalize(self.stock_item.quantity) 

1940 self.quantity = self.stock_item.quantity 

1941 error = { 

1942 'quantity': _( 

1943 f'Allocated quantity ({q}) must not exceed available stock quantity ({a})' 

1944 ) 

1945 } 

1946 

1947 # Ensure that we do not 'over allocate' a stock item 

1948 available = decimal.Decimal(self.stock_item.quantity) 

1949 quantity = decimal.Decimal(self.quantity) 

1950 build_allocation_count = decimal.Decimal( 

1951 self.stock_item.build_allocation_count(exclude_allocations={'pk': self.pk}) 

1952 ) 

1953 sales_allocation_count = decimal.Decimal( 

1954 self.stock_item.sales_order_allocation_count() 

1955 ) 

1956 

1957 total_allocation = build_allocation_count + sales_allocation_count + quantity 

1958 

1959 if total_allocation > available: 

1960 error = {'quantity': _('Stock item is over-allocated')} 

1961 

1962 if error and raise_error: 

1963 raise ValidationError(error) 

1964 

1965 @property 

1966 def build(self): 

1967 """Return the BuildOrder associated with this BuildItem.""" 

1968 return self.build_line.build if self.build_line else None 

1969 

1970 @property 

1971 def bom_item(self): 

1972 """Return the BomItem associated with this BuildItem.""" 

1973 return self.build_line.bom_item if self.build_line else None 

1974 

1975 @transaction.atomic 

1976 def complete_allocation(self, quantity=None, notes: str = '', user=None) -> None: 

1977 """Complete the allocation of this BuildItem into the output stock item. 

1978 

1979 Arguments: 

1980 quantity: The quantity to allocate (default is the full quantity) 

1981 notes: Additional notes to add to the transaction 

1982 user: The user completing the allocation 

1983 

1984 - If the referenced part is trackable, the stock item will be *installed* into the build output 

1985 - If the referenced part is *not* trackable, the stock item will be *consumed* by the build order 

1986 

1987 TODO: This is quite expensive (in terms of number of database hits) - and requires some thought 

1988 TODO: Revisit, and refactor! 

1989 

1990 """ 

1991 # If the quantity is not provided, use the quantity of this BuildItem 

1992 if quantity is None: 

1993 quantity = self.quantity 

1994 

1995 item = self.stock_item 

1996 

1997 # Ensure we are not allocating more than available 

1998 if quantity > item.quantity: 

1999 quantity = item.quantity 

2000 

2001 if quantity <= 0: 

2002 # There is nothing to consume or install: 

2003 # simply remove this (empty) allocation 

2004 self.delete() 

2005 return 

2006 

2007 # Split the allocated stock if there are more available than allocated 

2008 if item.quantity > quantity: 

2009 item = item.splitStock(quantity, None, user, notes=notes) 

2010 

2011 # For a trackable part, special consideration needed! 

2012 if item.part.trackable: 

2013 # Make sure we are pointing to the new item 

2014 self.stock_item = item 

2015 self.save() 

2016 

2017 # Install the stock item into the output 

2018 self.install_into.installStockItem( 

2019 item, quantity, user, notes, build=self.build 

2020 ) 

2021 

2022 else: 

2023 # Mark the item as "consumed" by the build order 

2024 item.consumed_by = self.build 

2025 item.location = None 

2026 item.save(add_note=False) 

2027 

2028 item.add_tracking_entry( 

2029 StockHistoryCode.BUILD_CONSUMED, 

2030 user, 

2031 notes=notes, 

2032 deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)}, 

2033 ) 

2034 

2035 # Increase the "consumed" count for the associated BuildLine 

2036 # Increment at the database level to prevent lost updates 

2037 # (multiple allocations against the same BuildLine may complete concurrently) 

2038 self.build_line.consumed = F('consumed') + quantity 

2039 self.build_line.save(update_fields=['consumed']) 

2040 self.build_line.refresh_from_db(fields=['consumed']) 

2041 

2042 # Decrease the allocated quantity 

2043 self.quantity = max(0, self.quantity - quantity) 

2044 

2045 if self.quantity <= 0: 

2046 self.delete() 

2047 else: 

2048 self.save() 

2049 

2050 build_line = models.ForeignKey( 

2051 BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations' 

2052 ) 

2053 

2054 stock_item = models.ForeignKey( 

2055 'stock.StockItem', 

2056 on_delete=models.CASCADE, 

2057 related_name='allocations', 

2058 verbose_name=_('Stock Item'), 

2059 help_text=_('Source stock item'), 

2060 limit_choices_to={'sales_order': None, 'belongs_to': None}, 

2061 ) 

2062 

2063 quantity = models.DecimalField( 

2064 decimal_places=5, 

2065 max_digits=15, 

2066 default=1, 

2067 validators=[MinValueValidator(0)], 

2068 verbose_name=_('Quantity'), 

2069 help_text=_('Stock quantity to allocate to build'), 

2070 ) 

2071 

2072 install_into = models.ForeignKey( 

2073 'stock.StockItem', 

2074 on_delete=models.SET_NULL, 

2075 blank=True, 

2076 null=True, 

2077 related_name='items_to_install', 

2078 verbose_name=_('Install into'), 

2079 help_text=_('Destination stock item'), 

2080 limit_choices_to={'is_building': True}, 

2081 )