Coverage for src/backend/InvenTree/part/models.py: 31%

1690 statements  

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

1"""Part database model definitions.""" 

2 

3from __future__ import annotations 

4 

5import hashlib 

6import inspect 

7import math 

8import os 

9import re 

10from datetime import timedelta 

11from decimal import ROUND_HALF_UP, Decimal, InvalidOperation 

12from typing import TypedDict, cast 

13 

14from django.conf import settings 

15from django.contrib.auth.models import User 

16from django.contrib.contenttypes.models import ContentType 

17from django.core.exceptions import ValidationError 

18from django.core.validators import MaxValueValidator, MinValueValidator 

19from django.db import models, transaction 

20from django.db.models import F, Q, QuerySet, Sum, UniqueConstraint 

21from django.db.models.functions import Coalesce 

22from django.db.models.signals import post_delete, post_save 

23from django.db.utils import IntegrityError 

24from django.dispatch import receiver 

25from django.urls import reverse 

26from django.utils.translation import gettext_lazy as _ 

27 

28import structlog 

29from django_cleanup import cleanup 

30from djmoney.contrib.exchange.exceptions import MissingRate 

31from djmoney.contrib.exchange.models import convert_money 

32from djmoney.money import Money 

33from mptt.managers import TreeManager 

34from mptt.models import TreeForeignKey 

35 

36import common.currency 

37import common.models 

38import InvenTree.conversion 

39import InvenTree.fields 

40import InvenTree.helpers 

41import InvenTree.models 

42import InvenTree.ready 

43import InvenTree.tasks 

44import part.helpers as part_helpers 

45import part.settings as part_settings 

46import report.mixins 

47import users.models 

48from build import models as BuildModels 

49from build.status_codes import BuildStatusGroups 

50from common.currency import currency_code_default 

51from common.icons import validate_icon 

52from common.settings import get_global_setting 

53from InvenTree import helpers, validators 

54from InvenTree.exceptions import log_error 

55from InvenTree.fields import InvenTreeURLField 

56from InvenTree.helpers import decimal2money, decimal2string, normalize 

57from order import models as OrderModels 

58from order.status_codes import ( 

59 PurchaseOrderStatus, 

60 PurchaseOrderStatusGroups, 

61 SalesOrderStatusGroups, 

62 TransferOrderStatusGroups, 

63) 

64from stock import models as StockModels 

65 

66logger = structlog.get_logger('inventree') 

67 

68 

69class PartCategory( 

70 InvenTree.models.PluginValidationMixin, 

71 InvenTree.models.InvenTreeParameterMixin, 

72 InvenTree.models.MetadataMixin, 

73 InvenTree.models.PathStringMixin, 

74 InvenTree.models.InvenTreeTree, 

75): 

76 """PartCategory provides hierarchical organization of Part objects. 

77 

78 Attributes: 

79 name: Name of this category 

80 parent: Parent category 

81 default_location: Default storage location for parts in this category or child categories 

82 default_keywords: Default keywords for parts created in this category 

83 """ 

84 

85 ITEM_PARENT_KEY = 'category' 

86 EXTRA_PATH_FIELDS = ['icon'] 

87 IMPORT_ID_FIELDS = ['pathstring', 'name'] 

88 

89 class Meta: 

90 """Metaclass defines extra model properties.""" 

91 

92 verbose_name = _('Part Category') 

93 verbose_name_plural = _('Part Categories') 

94 

95 def delete(self, *args, **kwargs): 

96 """Custom model deletion routine, which updates any child categories or parts. 

97 

98 This must be handled within a transaction.atomic(), otherwise the tree structure is damaged 

99 """ 

100 super().delete( 

101 delete_children=kwargs.get('delete_child_categories', False), 

102 delete_items=kwargs.get('delete_parts', False), 

103 ) 

104 

105 default_location = TreeForeignKey( 

106 'stock.StockLocation', 

107 related_name='default_categories', 

108 null=True, 

109 blank=True, 

110 on_delete=models.SET_NULL, 

111 verbose_name=_('Default Location'), 

112 help_text=_('Default location for parts in this category'), 

113 ) 

114 

115 structural = models.BooleanField( 

116 default=False, 

117 verbose_name=_('Structural'), 

118 help_text=_( 

119 'Parts may not be directly assigned to a structural category, ' 

120 'but may be assigned to child categories.' 

121 ), 

122 ) 

123 

124 default_keywords = models.CharField( 

125 null=True, 

126 blank=True, 

127 max_length=250, 

128 verbose_name=_('Default keywords'), 

129 help_text=_('Default keywords for parts in this category'), 

130 ) 

131 

132 _icon = models.CharField( 

133 blank=True, 

134 null=True, 

135 max_length=100, 

136 verbose_name=_('Icon'), 

137 help_text=_('Icon (optional)'), 

138 validators=[validate_icon], 

139 db_column='icon', 

140 ) 

141 

142 @property 

143 def icon(self): 

144 """Return the icon associated with this PartCategory or the default icon.""" 

145 if self._icon: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 return self._icon 

147 

148 if default_icon := get_global_setting('PART_CATEGORY_DEFAULT_ICON', cache=True): 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 return default_icon 

150 

151 return '' 

152 

153 @icon.setter 

154 def icon(self, value): 

155 """Setter for icon field.""" 

156 default_icon = get_global_setting('PART_CATEGORY_DEFAULT_ICON', cache=True) 

157 

158 # if icon is not defined previously and new value is default icon, do not save it 

159 if not self._icon and value == default_icon: 159 ↛ 162line 159 didn't jump to line 162 because the condition on line 159 was always true

160 return 

161 

162 self._icon = value 

163 

164 @staticmethod 

165 def get_api_url(): 

166 """Return the API url associated with the PartCategory model.""" 

167 return reverse('api-part-category-list') 

168 

169 def get_absolute_url(self): 

170 """Return the web URL associated with the detail view for this PartCategory instance.""" 

171 return helpers.pui_url(f'/part/category/{self.id}') 

172 

173 def clean(self): 

174 """Custom clean action for the PartCategory model. 

175 

176 Ensure that the structural parameter cannot get set if products already assigned to the category 

177 """ 

178 if self.pk and self.structural and self.partcount(False, False) > 0: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

179 raise ValidationError( 

180 _( 

181 'You cannot make this part category structural because some parts ' 

182 'are already assigned to it!' 

183 ) 

184 ) 

185 super().clean() 

186 

187 def get_parts(self, cascade=True) -> set[Part]: 

188 """Return a queryset for all parts under this category. 

189 

190 Args: 

191 cascade (bool, optional): If True, also look under subcategories. Defaults to True. 

192 

193 Returns: 

194 set[Part]: All matching parts 

195 """ 

196 if cascade: 

197 """Select any parts which exist in this category or any child categories.""" 

198 queryset = Part.objects.filter( 

199 category__in=self.getUniqueChildren(include_self=True) 

200 ) 

201 else: 

202 queryset = Part.objects.filter(category=self.pk) 

203 

204 return queryset 

205 

206 @property 

207 def item_count(self): 

208 """Return the number of parts contained in this PartCategory.""" 

209 return self.partcount() 

210 

211 def get_items(self, cascade=False): 

212 """Return a queryset containing the parts which exist in this category.""" 

213 return self.get_parts(cascade=cascade) 

214 

215 def partcount(self, cascade=True, active=False): 

216 """Return the total part count under this category (including children of child categories).""" 

217 query = self.get_parts(cascade=cascade) 

218 

219 if active: 

220 query = query.filter(active=True) 

221 

222 return query.count() 

223 

224 def prefetch_parts_parameters(self, cascade=True): 

225 """Prefectch parts parameters.""" 

226 return ( 

227 self 

228 .get_parts(cascade=cascade) 

229 .prefetch_related('parameters_list', 'parameters_list__template') 

230 .all() 

231 ) 

232 

233 def get_unique_parameters(self, cascade=True, prefetch=None): 

234 """Get all unique parameter names for all parts from this category.""" 

235 unique_parameters_names = [] 

236 

237 parts = prefetch or self.prefetch_parts_parameters(cascade=cascade) 

238 

239 for part in parts: 

240 for parameter in part.parameters_list.all(): 

241 parameter_name = parameter.template.name 

242 if parameter_name not in unique_parameters_names: 

243 unique_parameters_names.append(parameter_name) 

244 

245 return sorted(unique_parameters_names) 

246 

247 def get_parts_parameters(self, cascade=True, prefetch=None): 

248 """Get all parameter names and values for all parts from this category.""" 

249 category_parameters = [] 

250 

251 parts = prefetch or self.prefetch_parts_parameters(cascade=cascade) 

252 

253 for part in parts: 

254 part_parameters = { 

255 'pk': part.pk, 

256 'name': part.name, 

257 'description': part.description, 

258 } 

259 # Add IPN only if it exists 

260 if part.IPN: 

261 part_parameters['IPN'] = part.IPN 

262 

263 for parameter in part.parameters_list.all(): 

264 parameter_name = parameter.template.name 

265 parameter_value = parameter.data 

266 part_parameters[parameter_name] = parameter_value 

267 

268 category_parameters.append(part_parameters) 

269 

270 return category_parameters 

271 

272 @classmethod 

273 def get_parent_categories(cls): 

274 """Return tuple list of parent (root) categories.""" 

275 # Get root nodes 

276 root_categories = cls.objects.filter(level=0) 

277 

278 parent_categories = [] 

279 for category in root_categories: 

280 parent_categories.append((category.id, category.name)) 

281 

282 return parent_categories 

283 

284 def get_parameter_templates(self): 

285 """Return parameter templates associated to category.""" 

286 prefetch = PartCategoryParameterTemplate.objects.prefetch_related( 

287 'category', 'parameter' 

288 ) 

289 

290 return prefetch.filter(category=self.id) 

291 

292 def get_subscribers(self, include_parents: bool = True) -> list[User]: 

293 """Return a list of users who subscribe to this PartCategory. 

294 

295 Arguments: 

296 include_parents (bool): If True, include users who subscribe to parent categories. 

297 

298 Returns: 

299 list[User]: List of users who subscribe to this category. 

300 """ 

301 subscribers = set() 

302 

303 if include_parents: 

304 cats = self.get_ancestors(include_self=True) 

305 queryset = PartCategoryStar.objects.filter(category__in=cats) 

306 else: 

307 queryset = PartCategoryStar.objects.filter(category=self) 

308 

309 for result in queryset: 

310 subscribers.add(result.user) 

311 

312 return list(subscribers) 

313 

314 def is_starred_by(self, user, **kwargs): 

315 """Returns True if the specified user subscribes to this category.""" 

316 return user in self.get_subscribers(**kwargs) 

317 

318 def set_starred(self, user, status: bool, **kwargs) -> None: 

319 """Set the "subscription" status of this PartCategory against the specified user.""" 

320 if not user: 

321 return 

322 

323 if self.is_starred_by(user, **kwargs) == status: 

324 return 

325 

326 if status: 

327 PartCategoryStar.objects.create(category=self, user=user) 

328 else: 

329 # Note that this won't actually stop the user being subscribed, 

330 # if the user is subscribed to a parent category 

331 PartCategoryStar.objects.filter(category=self, user=user).delete() 

332 

333 

334def rename_part_image(instance, filename): 

335 """Function for renaming a part image file. 

336 

337 Args: 

338 instance: Instance of a Part object 

339 filename: Name of original uploaded file 

340 

341 Returns: 

342 Cleaned filename in format part_<n>_img 

343 """ 

344 base = part_helpers.PART_IMAGE_DIR 

345 fname = os.path.basename(filename) 

346 

347 return os.path.join(base, fname) 

348 

349 

350class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel): 

351 """A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a ParameterTemplate. 

352 

353 Multiple ParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation. 

354 

355 Attributes: 

356 category: Reference to a single PartCategory object 

357 template: Reference to a single ParameterTemplate object 

358 default_value: The default value for the parameter in the context of the selected category 

359 """ 

360 

361 @staticmethod 

362 def get_api_url(): 

363 """Return the API endpoint URL associated with the PartCategoryParameterTemplate model.""" 

364 return reverse('api-part-category-parameter-list') 

365 

366 class Meta: 

367 """Metaclass providing extra model definition.""" 

368 

369 verbose_name = _('Part Category Parameter Template') 

370 

371 constraints = [ 

372 UniqueConstraint( 

373 fields=['category', 'template'], name='unique_category_parameter_pair' 

374 ) 

375 ] 

376 

377 def __str__(self): 

378 """String representation of a PartCategoryParameterTemplate (admin interface).""" 

379 if self.default_value: 

380 return f'{self.category.name} | {self.template.name} | {self.default_value}' 

381 return f'{self.category.name} | {self.template.name}' 

382 

383 def clean(self): 

384 """Validate this PartCategoryParameterTemplate instance. 

385 

386 Checks the provided 'default_value', and (if not blank), ensure it is valid. 

387 """ 

388 super().clean() 

389 

390 self.default_value = ( 

391 '' if self.default_value is None else str(self.default_value.strip()) 

392 ) 

393 

394 if ( 

395 self.default_value 

396 and get_global_setting( 

397 'PARAMETER_ENFORCE_UNITS', True, cache=False, create=False 

398 ) 

399 and self.template.units 

400 ): 

401 try: 

402 InvenTree.conversion.convert_physical_value( 

403 self.default_value, self.template.units 

404 ) 

405 except ValidationError as e: 

406 raise ValidationError({'default_value': e.message}) 

407 

408 category = models.ForeignKey( 

409 PartCategory, 

410 on_delete=models.CASCADE, 

411 related_name='parameter_templates', 

412 verbose_name=_('Category'), 

413 help_text=_('Part Category'), 

414 ) 

415 

416 template = models.ForeignKey( 

417 common.models.ParameterTemplate, 

418 on_delete=models.CASCADE, 

419 related_name='part_categories', 

420 ) 

421 

422 default_value = models.CharField( 

423 max_length=500, 

424 blank=True, 

425 verbose_name=_('Default Value'), 

426 help_text=_('Default Parameter Value'), 

427 ) 

428 

429 

430class PartReportContext(report.mixins.BaseReportContext, TypedDict): 

431 """Report context for the Part model. 

432 

433 Attributes: 

434 bom_items: Query set of all BomItem objects associated with the Part 

435 category: The PartCategory object associated with the Part 

436 description: The description field of the Part 

437 IPN: The IPN (internal part number) of the Part 

438 name: The name of the Part 

439 parameters: Dict object containing the parameters associated with the Part 

440 part: The Part object itself 

441 qr_data: Formatted QR code data for the Part 

442 qr_url: Generated URL for embedding in a QR code 

443 revision: The revision of the Part 

444 test_template_list: List of test templates associated with the Part 

445 test_templates: Dict object of test templates associated with the Part 

446 """ 

447 

448 bom_items: report.mixins.QuerySet[BomItem] 

449 category: PartCategory | None 

450 description: str 

451 IPN: str | None 

452 name: str 

453 parameters: dict[str, str] 

454 part: Part 

455 qr_data: str 

456 qr_url: str 

457 revision: str | None 

458 test_template_list: report.mixins.QuerySet[PartTestTemplate] 

459 test_templates: dict[str, PartTestTemplate] 

460 

461 

462@cleanup.ignore 

463class Part( 

464 InvenTree.models.PluginValidationMixin, 

465 InvenTree.models.InvenTreeParameterMixin, 

466 InvenTree.models.InvenTreeAttachmentMixin, 

467 InvenTree.models.InvenTreeBarcodeMixin, 

468 InvenTree.models.InvenTreeTagsMixin, 

469 InvenTree.models.InvenTreeNotesMixin, 

470 report.mixins.InvenTreeReportMixin, 

471 InvenTree.models.InvenTreeImageMixin, 

472 InvenTree.models.MetadataMixin, 

473 InvenTree.models.InvenTreeTree, 

474): 

475 """The Part object represents an abstract part, the 'concept' of an actual entity. 

476 

477 An actual physical instance of a Part is a StockItem which is treated separately. 

478 

479 Parts can be used to create other parts (as part of a Bill of Materials or BOM). 

480 

481 Attributes: 

482 name: Brief name for this part 

483 variant: Optional variant number for this part - Must be unique for the part name 

484 category: The PartCategory to which this part belongs 

485 description: Longer form description of the part 

486 keywords: Optional keywords for improving part search results 

487 IPN: Internal part number (optional) 

488 revision: Part revision 

489 is_template: If True, this part is a 'template' part 

490 link: Link to an external page with more information about this part (e.g. internal Wiki) 

491 image: Image of this part 

492 default_location: Where the item is normally stored (may be null) 

493 default_expiry: The default expiry duration for any StockItem instances of this part 

494 minimum_stock: Minimum preferred quantity to keep in stock 

495 maximum_stock: Maximum preferred quantity to keep in stock 

496 units: Units of measure for this part (default='pcs') 

497 salable: Can this part be sold to customers? 

498 assembly: Can this part be build from other parts? 

499 component: Can this part be used to make other parts? 

500 purchaseable: Can this part be purchased from suppliers? 

501 trackable: Trackable parts can have unique serial numbers assigned, etc, etc 

502 testable: Testable parts can have test results recorded against their stock items 

503 active: Is this part active? Parts are deactivated instead of being deleted 

504 locked: This part is locked and cannot be edited 

505 virtual: Is this part "virtual"? e.g. a software product or similar 

506 notes: Additional notes field for this part 

507 creation_date: Date that this part was added to the database 

508 creation_user: User who added this part to the database 

509 responsible_owner: Owner (either user or group) which is responsible for this part (optional) 

510 

511 BOM (Bill of Materials) related attributes: 

512 bom_checksum: Checksum for the BOM of this part 

513 bom_validated: Boolean field indicating if the BOM is valid (checksum matches) 

514 bom_checked_by: User who last checked the BOM for this part 

515 bom_checked_date: Date when the BOM was last checked 

516 """ 

517 

518 NODE_PARENT_KEY = 'variant_of' 

519 IMAGE_RENAME = rename_part_image 

520 IMPORT_ID_FIELDS = ['IPN', 'name'] 

521 

522 objects = TreeManager() 

523 

524 class Meta: 

525 """Metaclass defines extra model properties.""" 

526 

527 verbose_name = _('Part') 

528 verbose_name_plural = _('Parts') 

529 ordering = ['name'] 

530 constraints = [ 

531 UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part') 

532 ] 

533 

534 class MPTTMeta: 

535 """MPTT Metaclass options.""" 

536 

537 # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent 

538 parent_attr = 'variant_of' 

539 

540 @staticmethod 

541 def get_api_url(): 

542 """Return the list API endpoint URL associated with the Part model.""" 

543 return reverse('api-part-list') 

544 

545 def api_instance_filters(self): 

546 """Return API query filters for limiting field results against this instance.""" 

547 return {'variant_of': {'exclude_tree': self.pk}} 

548 

549 @classmethod 

550 def barcode_model_type_code(cls): 

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

552 return 'PA' 

553 

554 def report_context(self) -> PartReportContext: 

555 """Return custom report context information.""" 

556 return { 

557 'bom_items': cast(report.mixins.QuerySet['BomItem'], self.get_bom_items()), 

558 'category': self.category, 

559 'description': self.description, 

560 'IPN': self.IPN, 

561 'name': self.name, 

562 'parameters': self.parameters_map(), 

563 'part': self, 

564 'qr_data': self.barcode, 

565 'qr_url': self.get_absolute_url(), 

566 'revision': self.revision, 

567 'test_template_list': self.getTestTemplates(), 

568 'test_templates': self.getTestTemplateMap(), 

569 } 

570 

571 def check_parameter_delete(self, parameter): 

572 """Custom delete check for Parameter instances associated with this Part.""" 

573 if self.locked and get_global_setting('PART_ENABLE_LOCKING'): 

574 raise ValidationError(_('Cannot delete parameters of a locked part')) 

575 

576 def check_parameter_save(self, parameter): 

577 """Custom save check for Parameter instances associated with this Part.""" 

578 if self.locked and get_global_setting('PART_ENABLE_LOCKING'): 

579 raise ValidationError(_('Cannot modify parameters of a locked part')) 

580 

581 def delete(self, **kwargs): 

582 """Custom delete method for the Part model. 

583 

584 Prevents deletion of a Part if any of the following conditions are met: 

585 

586 - The part is still active 

587 - The part is used in a BOM for a different part. 

588 """ 

589 if self.locked and get_global_setting('PART_ENABLE_LOCKING'): 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 raise ValidationError(_('Cannot delete this part as it is locked')) 

591 

592 if self.active: 592 ↛ 595line 592 didn't jump to line 595 because the condition on line 592 was always true

593 raise ValidationError(_('Cannot delete this part as it is still active')) 

594 

595 if not get_global_setting('PART_ALLOW_DELETE_FROM_ASSEMBLY', cache=False): 

596 if BomItem.objects.filter(sub_part=self).exists(): 

597 raise ValidationError( 

598 _('Cannot delete this part as it is used in an assembly') 

599 ) 

600 

601 super().delete() 

602 

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

604 """Overrides the save function for the Part model. 

605 

606 If the part image has been updated, then check if the "old" (previous) image is still used by another part. 

607 If not, it is considered "orphaned" and will be deleted. 

608 """ 

609 _new = False 

610 if self.pk: 

611 try: 

612 previous = Part.objects.get(pk=self.pk) 

613 

614 # Image has been changed 

615 if previous.image is not None and self.image != previous.image: 

616 # Are there any (other) parts which reference the image? 

617 n_refs = ( 

618 Part.objects 

619 .filter(image=previous.image) 

620 .exclude(pk=self.pk) 

621 .count() 

622 ) 

623 

624 if n_refs == 0: 

625 logger.info("Deleting unused image file '%s'", previous.image) 

626 previous.image.delete(save=False) 

627 except Part.DoesNotExist: 

628 pass 

629 else: 

630 _new = True 

631 

632 self.full_clean() 

633 

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

635 

636 if _new: 

637 # Only run if the check was not run previously (due to not existing in the database) 

638 self.ensure_trackable() 

639 

640 def __str__(self): 

641 """Return a string representation of the Part (for use in the admin interface).""" 

642 return f'{self.full_name} - {self.description}' 

643 

644 def get_parts_in_bom(self, **kwargs): 

645 """Return a list of all parts in the BOM for this part. 

646 

647 Takes into account substitutes, variant parts, and inherited BOM items 

648 """ 

649 parts = set() 

650 

651 for bom_item in self.get_bom_items(**kwargs): 

652 for part in bom_item.get_valid_parts_for_allocation(): 

653 parts.add(part) 

654 

655 return parts 

656 

657 def check_if_part_in_bom(self, other_part, **kwargs): 

658 """Check if the other_part is in the BOM for *this* part. 

659 

660 Note: 

661 - Accounts for substitute parts 

662 - Accounts for variant BOMs 

663 """ 

664 return other_part in self.get_parts_in_bom(**kwargs) 

665 

666 def check_add_to_bom(self, parent, raise_error=False, recursive=True): 

667 """Check if this Part can be added to the BOM of another part. 

668 

669 This will fail if: 

670 

671 a) The parent part is the same as this one 

672 b) The parent part exists in the same variant tree as this one 

673 c) The parent part is used in the BOM for *this* part 

674 d) The parent part is used in the BOM for any child parts under this one 

675 """ 

676 result = True 

677 

678 try: 

679 if self.pk == parent.pk: 

680 raise ValidationError({ 

681 'sub_part': _( 

682 f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)" 

683 ) 

684 }) 

685 

686 if self.tree_id == parent.tree_id: 

687 raise ValidationError({ 

688 'sub_part': _( 

689 f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)" 

690 ) 

691 }) 

692 

693 bom_items = self.get_bom_items() 

694 

695 # Ensure that the parent part does not appear under any child BOM item! 

696 for item in bom_items.all(): 

697 # Check for simple match 

698 if item.sub_part == parent: 

699 raise ValidationError({ 

700 'sub_part': _( 

701 f"Part '{parent}' is used in BOM for '{self}' (recursive)" 

702 ) 

703 }) 

704 

705 # And recursively check too 

706 if recursive: 

707 result = result and item.sub_part.check_add_to_bom( 

708 parent, recursive=True, raise_error=raise_error 

709 ) 

710 

711 except ValidationError as e: 

712 if raise_error: 

713 raise e 

714 else: 

715 return False 

716 

717 return result 

718 

719 def validate_name(self, raise_error=True): 

720 """Validate the name field for this Part instance. 

721 

722 This function is exposed to any Validation plugins, and thus can be customized. 

723 """ 

724 from plugin import PluginMixinEnum, registry 

725 

726 # Skip plugin validation checks during read-only management commands 

727 if not InvenTree.ready.isReadOnlyCommand(): 727 ↛ exitline 727 didn't return from function 'validate_name' because the condition on line 727 was always true

728 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 728 ↛ 732line 728 didn't jump to line 732 because the loop on line 728 never started

729 # Run the name through each custom validator 

730 # If the plugin returns 'True' we will skip any subsequent validation 

731 

732 try: 

733 result = plugin.validate_part_name(self.name, self) 

734 if result: 

735 return 

736 except ValidationError as exc: 

737 if raise_error: 

738 raise ValidationError({'name': exc.message}) 

739 except Exception: 

740 log_error('validate_part_name', plugin=plugin.slug) 

741 

742 def validate_ipn(self, raise_error=True): 

743 """Ensure that the IPN (internal part number) is valid for this Part". 

744 

745 - Validation is handled by custom plugins 

746 - By default, no validation checks are performed 

747 """ 

748 from plugin import PluginMixinEnum, registry 

749 

750 # Skip plugin validation checks during read-only management commands 

751 if not InvenTree.ready.isReadOnlyCommand(): 751 ↛ 766line 751 didn't jump to line 766 because the condition on line 751 was always true

752 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 752 ↛ 753line 752 didn't jump to line 753 because the loop on line 752 never started

753 try: 

754 result = plugin.validate_part_ipn(self.IPN, self) 

755 

756 if result: 

757 # A "true" result force skips any subsequent checks 

758 break 

759 except ValidationError as exc: 

760 if raise_error: 

761 raise ValidationError({'IPN': exc.message}) 

762 except Exception: 

763 log_error('validate_part_ipn', plugin=plugin.slug) 

764 

765 # If we get to here, none of the plugins have raised an error 

766 pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip() 

767 

768 if pattern: 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true

769 match = re.search(pattern, self.IPN) 

770 

771 if match is None: 

772 raise ValidationError(_(f'IPN must match regex pattern {pattern}')) 

773 

774 def validate_revision(self): 

775 """Check the 'revision' and 'revision_of' fields.""" 

776 # Part cannot be a revision of itself 

777 if self.revision_of: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

778 if self.revision_of == self: 

779 raise ValidationError({ 

780 'revision_of': _('Part cannot be a revision of itself') 

781 }) 

782 

783 # If this part is a revision, it must have a revision code 

784 if not self.revision: 

785 raise ValidationError({ 

786 'revision': _( 

787 'Revision code must be specified for a part marked as a revision' 

788 ) 

789 }) 

790 

791 if get_global_setting('PART_REVISION_ASSEMBLY_ONLY'): 

792 if not self.assembly or not self.revision_of.assembly: 

793 raise ValidationError({ 

794 'revision_of': _( 

795 'Revisions are only allowed for assembly parts' 

796 ) 

797 }) 

798 

799 # Cannot have a revision of a "template" part 

800 if self.revision_of.is_template: 

801 raise ValidationError({ 

802 'revision_of': _('Cannot make a revision of a template part') 

803 }) 

804 

805 # parent part must point to the same template (via variant_of) 

806 if self.variant_of != self.revision_of.variant_of: 

807 raise ValidationError({ 

808 'revision_of': _('Parent part must point to the same template') 

809 }) 

810 

811 def validate_serial_number( 

812 self, 

813 serial: str, 

814 stock_item=None, 

815 check_duplicates=True, 

816 raise_error=False, 

817 **kwargs, 

818 ): 

819 """Validate a serial number against this Part instance. 

820 

821 Note: This function is exposed to any Validation plugins, and thus can be customized. 

822 

823 Any plugins which implement the 'validate_serial_number' method have three possible outcomes: 

824 

825 - Decide the serial is objectionable and raise a django.core.exceptions.ValidationError 

826 - Decide the serial is acceptable, and return None to proceed to other tests 

827 - Decide the serial is acceptable, and return True to skip any further tests 

828 

829 Arguments: 

830 serial: The proposed serial number 

831 stock_item: (optional) A StockItem instance which has this serial number assigned (e.g. testing for duplicates) 

832 check_duplicates: If True, checks for duplicate serial numbers in the database. 

833 raise_error: If False, and ValidationError(s) will be handled 

834 

835 Returns: 

836 True if serial number is 'valid' else False 

837 

838 Raises: 

839 ValidationError if serial number is invalid and raise_error = True 

840 """ 

841 from plugin import PluginMixinEnum, registry 

842 

843 serial = str(serial).strip() 

844 

845 if not InvenTree.ready.isReadOnlyCommand(): 

846 # First, throw the serial number against each of the loaded validation plugins 

847 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 

848 # Run the serial number through each custom validator 

849 # If the plugin returns 'True' we will skip any subsequent validation 

850 

851 try: 

852 result = False 

853 

854 if hasattr(plugin, 'validate_serial_number'): 

855 signature = inspect.signature(plugin.validate_serial_number) 

856 

857 if 'stock_item' in signature.parameters: 

858 # 2024-08-21: New method signature accepts a 'stock_item' parameter 

859 result = plugin.validate_serial_number( 

860 serial, self, stock_item=stock_item 

861 ) 

862 else: 

863 # Old method signature - does not accept a 'stock_item' parameter 

864 result = plugin.validate_serial_number(serial, self) 

865 

866 if result is True: 

867 return True 

868 except ValidationError as exc: 

869 if raise_error: 

870 # Re-throw the error 

871 raise exc 

872 else: 

873 return False 

874 except Exception: 

875 log_error('validate_serial_number', plugin=plugin.slug) 

876 

877 """ 

878 If we are here, none of the loaded plugins (if any) threw an error or exited early 

879 

880 Now, we run the "default" serial number validation routine, 

881 which checks that the serial number is not duplicated 

882 """ 

883 

884 if not check_duplicates: 

885 return 

886 

887 # from part.models import Part 

888 from stock.models import StockItem 

889 

890 if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): 

891 # Serial number must be unique across *all* parts 

892 parts = Part.objects.all() 

893 else: 

894 # Serial number must only be unique across this part "tree" 

895 parts = Part.objects.filter(tree_id=self.tree_id) 

896 

897 stock = StockItem.objects.filter(part__in=parts, serial=serial) 

898 

899 if stock_item: 

900 # Exclude existing StockItem from query 

901 stock = stock.exclude(pk=stock_item.pk) 

902 

903 if stock.exists(): 

904 if raise_error: 

905 raise ValidationError( 

906 _('Stock item with this serial number already exists') 

907 + ': ' 

908 + serial 

909 ) 

910 else: 

911 return False 

912 else: 

913 # This serial number is perfectly valid 

914 return True 

915 

916 def find_conflicting_serial_numbers(self, serials: list) -> list: 

917 """For a provided list of serials, return a list of those which are conflicting.""" 

918 # from part.models import Part 

919 from stock.models import StockItem 

920 

921 conflicts = [] 

922 

923 # First, check for raw conflicts based on efficient database queries 

924 if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): 

925 # Serial number must be unique across *all* parts 

926 parts = Part.objects.all() 

927 else: 

928 # Serial number must only be unique across this part "tree" 

929 parts = Part.objects.filter(tree_id=self.tree_id) 

930 

931 items = StockItem.objects.filter(part__in=parts, serial__in=serials) 

932 items = items.order_by('serial_int', 'serial') 

933 

934 for item in items: 

935 conflicts.append(item.serial) 

936 

937 for serial in serials: 

938 if serial in conflicts: 

939 # Already found a conflict, no need to check further 

940 continue 

941 

942 try: 

943 self.validate_serial_number( 

944 serial, raise_error=True, check_duplicates=False 

945 ) 

946 except ValidationError: 

947 # Serial number is invalid (as determined by plugin) 

948 conflicts.append(serial) 

949 

950 return conflicts 

951 

952 def get_latest_serial_number(self, allow_plugins=True): 

953 """Find the 'latest' serial number for this Part. 

954 

955 Here we attempt to find the "highest" serial number which exists for this Part. 

956 There are a number of edge cases where this method can fail, 

957 but this is accepted to keep database performance at a reasonable level. 

958 

959 Note: Serial numbers must be unique across an entire Part "tree", 

960 so we filter by the entire tree. 

961 

962 Returns: 

963 The latest serial number specified for this part, or None 

964 """ 

965 from plugin import PluginMixinEnum, registry 

966 

967 if allow_plugins and not InvenTree.ready.isReadOnlyCommand(): 967 ↛ 979line 967 didn't jump to line 979 because the condition on line 967 was always true

968 # Check with plugin system 

969 # If any plugin returns a non-null result, that takes priority 

970 for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): 970 ↛ 971line 970 didn't jump to line 971 because the loop on line 970 never started

971 try: 

972 result = plugin.get_latest_serial_number(self) 

973 if result is not None: 

974 return str(result) 

975 except Exception: 

976 log_error('get_latest_serial_number', plugin=plugin.slug) 

977 

978 # No plugin returned a result, so we will run the default query 

979 stock = ( 

980 StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='') 

981 ) 

982 

983 # Generate a query for any stock items for this part variant tree with non-empty serial numbers 

984 if not get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): 984 ↛ 989line 984 didn't jump to line 989 because the condition on line 984 was always true

985 # Serial numbers are unique across part trees 

986 stock = stock.filter(part__tree_id=self.tree_id) 

987 

988 # There are no matching StockItem objects (skip further tests) 

989 if not stock.exists(): 989 ↛ 993line 989 didn't jump to line 993 because the condition on line 989 was always true

990 return None 

991 

992 # Sort in descending order 

993 stock = stock.order_by('-serial_int', '-serial', '-pk') 

994 

995 # Return the first serial value 

996 return stock[0].serial 

997 

998 def get_next_serial_number(self): 

999 """Return the 'next' serial number in sequence.""" 

1000 sn = self.get_latest_serial_number() 

1001 

1002 return InvenTree.helpers.increment_serial_number(sn, self) 

1003 

1004 @property 

1005 def full_name(self) -> str: 

1006 """Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings.""" 

1007 return part_helpers.render_part_full_name(self) 

1008 

1009 def get_absolute_url(self): 

1010 """Return the web URL for viewing this part.""" 

1011 return helpers.pui_url(f'/part/{self.id}') 

1012 

1013 def validate_unique(self, exclude=None): 

1014 """Validate that this Part instance is 'unique'. 

1015 

1016 Uniqueness is checked across the following (case insensitive) fields: 

1017 - Name 

1018 - IPN 

1019 - Revision 

1020 

1021 e.g. there can exist multiple parts with the same name, but only if 

1022 they have a different revision or internal part number. 

1023 """ 

1024 super().validate_unique(exclude) 

1025 

1026 # User can decide whether duplicate IPN (Internal Part Number) values are allowed 

1027 allow_duplicate_ipn = get_global_setting('PART_ALLOW_DUPLICATE_IPN') 

1028 

1029 # Raise an error if an IPN is set, and it is a duplicate 

1030 if self.IPN and not allow_duplicate_ipn: 1030 ↛ 1031line 1030 didn't jump to line 1031 because the condition on line 1030 was never true

1031 parts = Part.objects.filter(IPN__iexact=self.IPN) 

1032 parts = parts.exclude(pk=self.pk) 

1033 

1034 if parts.exists(): 

1035 raise ValidationError({ 

1036 'IPN': _('Duplicate IPN not allowed in part settings') 

1037 }) 

1038 

1039 if ( 1039 ↛ 1049line 1039 didn't jump to line 1049 because the condition on line 1039 was never true

1040 self.revision_of 

1041 and self.revision 

1042 and ( 

1043 Part.objects 

1044 .exclude(pk=self.pk) 

1045 .filter(revision_of=self.revision_of, revision=self.revision) 

1046 .exists() 

1047 ) 

1048 ): 

1049 raise ValidationError(_('Duplicate part revision already exists.')) 

1050 

1051 # Ensure unique across (Name, revision, IPN) (as specified) 

1052 if (self.revision or self.IPN) and ( 1052 ↛ 1058line 1052 didn't jump to line 1058 because the condition on line 1052 was never true

1053 Part.objects 

1054 .exclude(pk=self.pk) 

1055 .filter(name=self.name, revision=self.revision, IPN=self.IPN) 

1056 .exists() 

1057 ): 

1058 raise ValidationError( 

1059 _('Part with this Name, IPN and Revision already exists.') 

1060 ) 

1061 

1062 def clean(self): 

1063 """Perform cleaning operations for the Part model. 

1064 

1065 - Check if the PartCategory is not structural 

1066 

1067 - Update trackable status: 

1068 If this part is trackable, and it is used in the BOM 

1069 for a parent part which is *not* trackable, 

1070 then we will force the parent part to be trackable. 

1071 """ 

1072 if self.category is not None and self.category.structural: 1072 ↛ 1073line 1072 didn't jump to line 1073 because the condition on line 1072 was never true

1073 raise ValidationError({ 

1074 'category': _('Parts cannot be assigned to structural part categories!') 

1075 }) 

1076 

1077 # Check the 'revision' and 'revision_of' fields 

1078 self.validate_revision() 

1079 

1080 super().clean() 

1081 

1082 # Strip IPN field 

1083 if type(self.IPN) is str: 1083 ↛ 1087line 1083 didn't jump to line 1087 because the condition on line 1083 was always true

1084 self.IPN = self.IPN.strip() 

1085 

1086 # Run custom validation for the IPN field 

1087 self.validate_ipn() 

1088 

1089 # Run custom validation for the name field 

1090 self.validate_name() 

1091 

1092 if self.pk: 

1093 # Only run if the part already exists in the database 

1094 self.ensure_trackable() 

1095 

1096 def ensure_trackable(self): 

1097 """Ensure that trackable is set correctly downstream.""" 

1098 if self.trackable: 1098 ↛ 1099line 1098 didn't jump to line 1099 because the condition on line 1098 was never true

1099 for part in self.get_used_in(): 

1100 if not part.trackable: 

1101 part.trackable = True 

1102 part.clean() 

1103 part.save() 

1104 

1105 name = models.CharField( 

1106 max_length=100, blank=False, help_text=_('Part name'), verbose_name=_('Name') 

1107 ) 

1108 

1109 is_template = models.BooleanField( 

1110 default=part_settings.part_template_default, 

1111 verbose_name=_('Is Template'), 

1112 help_text=_('Is this part a template part?'), 

1113 ) 

1114 

1115 variant_of = models.ForeignKey( 

1116 'part.Part', 

1117 related_name='variants', 

1118 null=True, 

1119 blank=True, 

1120 limit_choices_to={'is_template': True}, 

1121 on_delete=models.SET_NULL, 

1122 help_text=_('Is this part a variant of another part?'), 

1123 verbose_name=_('Variant Of'), 

1124 ) 

1125 

1126 description = models.CharField( 

1127 max_length=250, 

1128 blank=True, 

1129 verbose_name=_('Description'), 

1130 help_text=_('Part description (optional)'), 

1131 ) 

1132 

1133 keywords = models.CharField( 

1134 max_length=250, 

1135 blank=True, 

1136 null=True, 

1137 verbose_name=_('Keywords'), 

1138 help_text=_('Part keywords to improve visibility in search results'), 

1139 ) 

1140 

1141 category = TreeForeignKey( 

1142 PartCategory, 

1143 related_name='parts', 

1144 null=True, 

1145 blank=True, 

1146 on_delete=models.DO_NOTHING, 

1147 verbose_name=_('Category'), 

1148 help_text=_('Part category'), 

1149 ) 

1150 

1151 IPN = models.CharField( 

1152 max_length=100, 

1153 blank=True, 

1154 null=True, 

1155 verbose_name=_('IPN'), 

1156 help_text=_('Internal Part Number'), 

1157 ) 

1158 

1159 revision = models.CharField( 

1160 max_length=100, 

1161 blank=True, 

1162 null=True, 

1163 help_text=_('Part revision or version number'), 

1164 verbose_name=_('Revision'), 

1165 ) 

1166 

1167 revision_of = models.ForeignKey( 

1168 'part.Part', 

1169 related_name='revisions', 

1170 null=True, 

1171 blank=True, 

1172 on_delete=models.SET_NULL, 

1173 help_text=_('Is this part a revision of another part?'), 

1174 verbose_name=_('Revision Of'), 

1175 ) 

1176 

1177 link = InvenTreeURLField( 

1178 blank=True, 

1179 null=True, 

1180 verbose_name=_('Link'), 

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

1182 max_length=2000, 

1183 ) 

1184 

1185 default_location = TreeForeignKey( 

1186 'stock.StockLocation', 

1187 on_delete=models.SET_NULL, 

1188 blank=True, 

1189 null=True, 

1190 help_text=_('Where is this item normally stored?'), 

1191 related_name='default_parts', 

1192 verbose_name=_('Default Location'), 

1193 ) 

1194 

1195 def get_default_location(self): 

1196 """Get the default location for a Part (may be None). 

1197 

1198 If the Part does not specify a default location, 

1199 look at the Category this part is in. 

1200 The PartCategory object may also specify a default stock location 

1201 """ 

1202 if self.default_location: 

1203 return self.default_location 

1204 elif self.category: 

1205 # Traverse up the category tree until we find a default location 

1206 cats = self.category.get_ancestors(ascending=True, include_self=True) 

1207 

1208 for cat in cats: 

1209 if cat.default_location: 

1210 return cat.default_location 

1211 

1212 # Default case - no default category found 

1213 return None 

1214 

1215 @property 

1216 def default_supplier(self): 

1217 """Return the default (primary) SupplierPart for this Part. 

1218 

1219 This function is included for backwards compatibility, 

1220 as the 'Part' model used to have a 'default_supplier' field which was a ForeignKey to SupplierPart. 

1221 """ 

1222 return self.supplier_parts.filter(primary=True).first() 

1223 

1224 default_expiry = models.PositiveIntegerField( 

1225 default=0, 

1226 validators=[MinValueValidator(0)], 

1227 verbose_name=_('Default Expiry'), 

1228 help_text=_('Expiry time (in days) for stock items of this part'), 

1229 ) 

1230 

1231 minimum_stock = models.DecimalField( 

1232 max_digits=19, 

1233 decimal_places=6, 

1234 default=0, 

1235 validators=[MinValueValidator(0)], 

1236 verbose_name=_('Minimum Stock'), 

1237 help_text=_('Minimum allowed stock level'), 

1238 ) 

1239 

1240 maximum_stock = models.DecimalField( 

1241 max_digits=19, 

1242 decimal_places=6, 

1243 default=0, 

1244 validators=[MinValueValidator(0)], 

1245 verbose_name=_('Maximum Stock'), 

1246 help_text=_('Maximum allowed stock level'), 

1247 ) 

1248 

1249 units = models.CharField( 

1250 max_length=20, 

1251 default='', 

1252 blank=True, 

1253 null=True, 

1254 verbose_name=_('Units'), 

1255 help_text=_('Units of measure for this part'), 

1256 validators=[validators.validate_physical_units], 

1257 ) 

1258 

1259 assembly = models.BooleanField( 

1260 default=part_settings.part_assembly_default, 

1261 verbose_name=_('Assembly'), 

1262 help_text=_('Can this part be built from other parts?'), 

1263 ) 

1264 

1265 component = models.BooleanField( 

1266 default=part_settings.part_component_default, 

1267 verbose_name=_('Component'), 

1268 help_text=_('Can this part be used to build other parts?'), 

1269 ) 

1270 

1271 trackable = models.BooleanField( 

1272 default=part_settings.part_trackable_default, 

1273 verbose_name=_('Trackable'), 

1274 help_text=_('Does this part have tracking for unique items?'), 

1275 ) 

1276 

1277 testable = models.BooleanField( 

1278 default=False, 

1279 verbose_name=_('Testable'), 

1280 help_text=_('Can this part have test results recorded against it?'), 

1281 ) 

1282 

1283 purchaseable = models.BooleanField( 

1284 default=part_settings.part_purchaseable_default, 

1285 verbose_name=_('Purchaseable'), 

1286 help_text=_('Can this part be purchased from external suppliers?'), 

1287 ) 

1288 

1289 salable = models.BooleanField( 

1290 default=part_settings.part_salable_default, 

1291 verbose_name=_('Salable'), 

1292 help_text=_('Can this part be sold to customers?'), 

1293 ) 

1294 

1295 active = models.BooleanField( 

1296 default=True, verbose_name=_('Active'), help_text=_('Is this part active?') 

1297 ) 

1298 

1299 locked = models.BooleanField( 

1300 default=False, 

1301 verbose_name=_('Locked'), 

1302 help_text=_('Locked parts cannot be edited'), 

1303 ) 

1304 

1305 virtual = models.BooleanField( 

1306 default=part_settings.part_virtual_default, 

1307 verbose_name=_('Virtual'), 

1308 help_text=_('Is this a virtual part, such as a software product or license?'), 

1309 ) 

1310 

1311 bom_validated = models.BooleanField( 

1312 default=False, 

1313 verbose_name=_('BOM Validated'), 

1314 help_text=_('Is the BOM for this part valid?'), 

1315 ) 

1316 

1317 bom_checksum = models.CharField( 

1318 max_length=128, 

1319 blank=True, 

1320 verbose_name=_('BOM checksum'), 

1321 help_text=_('Stored BOM checksum'), 

1322 ) 

1323 

1324 bom_checked_by = models.ForeignKey( 

1325 User, 

1326 on_delete=models.SET_NULL, 

1327 blank=True, 

1328 null=True, 

1329 verbose_name=_('BOM checked by'), 

1330 related_name='boms_checked', 

1331 ) 

1332 

1333 bom_checked_date = models.DateField( 

1334 blank=True, null=True, verbose_name=_('BOM checked date') 

1335 ) 

1336 

1337 creation_date = models.DateField( 

1338 auto_now_add=True, 

1339 editable=False, 

1340 blank=True, 

1341 null=True, 

1342 verbose_name=_('Creation Date'), 

1343 ) 

1344 

1345 creation_user = models.ForeignKey( 

1346 User, 

1347 on_delete=models.SET_NULL, 

1348 blank=True, 

1349 null=True, 

1350 verbose_name=_('Creation User'), 

1351 related_name='parts_created', 

1352 ) 

1353 

1354 responsible_owner = models.ForeignKey( 

1355 users.models.Owner, 

1356 on_delete=models.SET_NULL, 

1357 blank=True, 

1358 null=True, 

1359 verbose_name=_('Responsible'), 

1360 help_text=_('Owner responsible for this part'), 

1361 related_name='parts_responsible', 

1362 ) 

1363 

1364 @property 

1365 def category_path(self): 

1366 """Return the category path of this Part instance.""" 

1367 if self.category: 

1368 return self.category.pathstring 

1369 return '' 

1370 

1371 @property 

1372 def available_stock(self): 

1373 """Return the total available stock. 

1374 

1375 - This subtracts stock which is already allocated to builds 

1376 """ 

1377 total = self.total_stock 

1378 total -= self.allocation_count() 

1379 

1380 return max(total, 0) 

1381 

1382 def requiring_build_orders(self, include_variants: bool = True): 

1383 """Return list of outstanding build orders which require this part. 

1384 

1385 Arguments: 

1386 include_variants: If True, include variants of this part in the calculation 

1387 """ 

1388 # List parts that this part is required for 

1389 

1390 if include_variants: 1390 ↛ 1394line 1390 didn't jump to line 1394 because the condition on line 1390 was always true

1391 # If we are including variants, get all parts in the variant tree 

1392 parts = list(self.get_descendants(include_self=True)) 

1393 else: 

1394 parts = [self] 

1395 

1396 used_in_parts = set() 

1397 

1398 for part in parts: 

1399 # Get all assemblies which use this part 

1400 used_in_parts.update(part.get_used_in()) 

1401 

1402 # Now, get a list of outstanding build orders which require this part 

1403 builds = BuildModels.Build.objects.filter( 

1404 part__in=list(used_in_parts), status__in=BuildStatusGroups.ACTIVE_CODES 

1405 ) 

1406 

1407 return builds 

1408 

1409 def required_build_order_quantity(self, include_variants: bool = True): 

1410 """Return the quantity of this part required for active build orders. 

1411 

1412 Arguments: 

1413 include_variants: If True, include variants of this part in the calculation 

1414 """ 

1415 # List active build orders which reference this part 

1416 builds = self.requiring_build_orders(include_variants=include_variants) 

1417 

1418 quantity = 0 

1419 

1420 if include_variants: 1420 ↛ 1423line 1420 didn't jump to line 1423 because the condition on line 1420 was always true

1421 matching_parts = list(self.get_descendants(include_self=True)) 

1422 else: 

1423 matching_parts = [self] 

1424 

1425 # Cache the BOM items that we query 

1426 # Keep a dict of part ID to BOM items 

1427 cached_bom_items: dict = {} 

1428 

1429 for build in builds: 1429 ↛ 1430line 1429 didn't jump to line 1430 because the loop on line 1429 never started

1430 if build.part.pk not in cached_bom_items: 

1431 # Get the BOM items for this part 

1432 bom_items = build.part.get_bom_items().filter( 

1433 sub_part__in=matching_parts 

1434 ) 

1435 cached_bom_items[build.part.pk] = bom_items 

1436 else: 

1437 bom_items = cached_bom_items[build.part.pk] 

1438 

1439 # Match BOM item to build 

1440 for bom_item in bom_items: 

1441 build_line = build.build_lines.filter(bom_item=bom_item).first() 

1442 

1443 if not build_line: 

1444 continue 

1445 

1446 line_quantity = max(0, build_line.quantity - build_line.consumed) 

1447 quantity += line_quantity 

1448 

1449 return quantity 

1450 

1451 def requiring_sales_orders(self, include_variants: bool = True): 

1452 """Return a list of sales orders which require this part. 

1453 

1454 Arguments: 

1455 include_variants: If True, include variants of this part in the calculation 

1456 """ 

1457 orders = set() 

1458 

1459 if include_variants: 

1460 parts = list(self.get_descendants(include_self=True)) 

1461 else: 

1462 parts = [self] 

1463 

1464 # Get a list of line items for open orders which match this part 

1465 open_lines = OrderModels.SalesOrderLineItem.objects.filter( 

1466 order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts 

1467 ) 

1468 

1469 for line in open_lines: 

1470 orders.add(line.order) 

1471 

1472 return orders 

1473 

1474 def required_sales_order_quantity(self, include_variants: bool = True): 

1475 """Return the quantity of this part required for active sales orders. 

1476 

1477 Arguments: 

1478 include_variants: If True, include variants of this part in the calculation 

1479 """ 

1480 if include_variants: 1480 ↛ 1483line 1480 didn't jump to line 1483 because the condition on line 1480 was always true

1481 parts = list(self.get_descendants(include_self=True)) 

1482 else: 

1483 parts = [self] 

1484 

1485 # Get a list of line items for open orders which match this part 

1486 open_lines = OrderModels.SalesOrderLineItem.objects.filter( 

1487 order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts 

1488 ) 

1489 

1490 quantity = 0 

1491 

1492 for line in open_lines: 1492 ↛ 1495line 1492 didn't jump to line 1495 because the loop on line 1492 never started

1493 # Determine the quantity "remaining" to be shipped out 

1494 

1495 if not line: 

1496 continue 

1497 

1498 remaining = max(line.quantity - line.shipped, 0) 

1499 quantity += remaining 

1500 

1501 return quantity 

1502 

1503 def required_order_quantity(self, include_variants: bool = True): 

1504 """Return total required to fulfil orders.""" 

1505 return self.required_build_order_quantity( 

1506 include_variants=include_variants 

1507 ) + self.required_sales_order_quantity(include_variants=include_variants) 

1508 

1509 @property 

1510 def quantity_to_order(self): 

1511 """Return the quantity needing to be ordered for this part. 

1512 

1513 Here, an "order" could be one of: 

1514 - Build Order 

1515 - Sales Order 

1516 

1517 To work out how many we need to order: 

1518 

1519 Stock on hand = self.total_stock 

1520 Required for orders = self.required_order_quantity() 

1521 Currently on order = self.on_order 

1522 Currently building = self.quantity_being_built 

1523 """ 

1524 # Total requirement 

1525 required = self.required_order_quantity() 

1526 

1527 # Subtract stock levels 

1528 required -= max(self.total_stock, self.minimum_stock) 

1529 

1530 # Subtract quantity on order 

1531 required -= self.on_order 

1532 

1533 # Subtract quantity being built 

1534 required -= self.quantity_being_built 

1535 

1536 return max(required, 0) 

1537 

1538 @property 

1539 def net_stock(self): 

1540 """Return the 'net' stock. 

1541 

1542 It takes into account: 

1543 - Stock on hand (total_stock) 

1544 - Stock on order (on_order) 

1545 - Stock allocated (allocation_count) 

1546 

1547 This number (unlike 'available_stock') can be negative. 

1548 """ 

1549 return self.total_stock - self.allocation_count() + self.on_order 

1550 

1551 def get_subscribers( 

1552 self, include_variants: bool = True, include_categories: bool = True 

1553 ) -> list[User]: 

1554 """Return a list of users who are 'subscribed' to this part. 

1555 

1556 Arguments: 

1557 include_variants: If True, include users who are subscribed to a variant part 

1558 include_categories: If True, include users who are subscribed to the category 

1559 

1560 Returns: 

1561 list[User]: A list of users who are subscribed to this part 

1562 

1563 A user may 'subscribe' to this part in the following ways: 

1564 

1565 a) Subscribing to the part instance directly 

1566 b) Subscribing to a template part "above" this part (if it is a variant) 

1567 c) Subscribing to the part category that this part belongs to 

1568 d) Subscribing to a parent category of the category in c) 

1569 """ 

1570 subscribers = set() 

1571 

1572 # Start by looking at direct subscriptions to a Part model 

1573 queryset = PartStar.objects.all() 

1574 

1575 if include_variants: 

1576 queryset = queryset.filter(part__in=self.get_ancestors(include_self=True)) 

1577 else: 

1578 queryset = queryset.filter(part=self) 

1579 

1580 for star in queryset: 

1581 subscribers.add(star.user) 

1582 

1583 if include_categories and self.category: 

1584 for sub in self.category.get_subscribers(): 

1585 subscribers.add(sub) 

1586 

1587 return list(subscribers) 

1588 

1589 def is_starred_by(self, user, **kwargs): 

1590 """Return True if the specified user subscribes to this part.""" 

1591 return user in self.get_subscribers(**kwargs) 

1592 

1593 def set_starred(self, user, status, **kwargs): 

1594 """Set the "subscription" status of this Part against the specified user.""" 

1595 if not user: 

1596 return 

1597 

1598 # Already subscribed? 

1599 if self.is_starred_by(user, **kwargs) == status: 

1600 return 

1601 

1602 if status: 

1603 PartStar.objects.create(part=self, user=user) 

1604 else: 

1605 # Note that this won't actually stop the user being subscribed, 

1606 # if the user is subscribed to a parent part or category 

1607 PartStar.objects.filter(part=self, user=user).delete() 

1608 

1609 @property 

1610 def can_build(self): 

1611 """Return the number of units that can be build with available stock.""" 

1612 import part.filters 

1613 

1614 # If this part does NOT have a BOM, result is simply the currently available stock 

1615 if not self.has_bom: 1615 ↛ 1619line 1615 didn't jump to line 1619 because the condition on line 1615 was always true

1616 return 0 

1617 

1618 # Ignore virtual parts when calculating the "can_build" quantity 

1619 queryset = self.get_bom_items(include_virtual=False) 

1620 

1621 # Ignore 'consumable' BOM items for this calculation 

1622 queryset = queryset.filter(consumable=False) 

1623 

1624 # Annotate the queryset with the 'can_build' quantity 

1625 queryset = part.filters.annotate_bom_item_can_build(queryset) 

1626 

1627 can_build_quantity = None 

1628 

1629 for value in queryset.values_list('can_build', flat=True): 

1630 if can_build_quantity is None: 

1631 can_build_quantity = value 

1632 else: 

1633 can_build_quantity = min(can_build_quantity, value) 

1634 

1635 if can_build_quantity is None: 

1636 # No BOM items, or no items which can be built 

1637 return 0 

1638 

1639 return int(max(can_build_quantity, 0)) 

1640 

1641 @property 

1642 def active_builds(self): 

1643 """Return a list of outstanding builds. 

1644 

1645 Builds marked as 'complete' or 'cancelled' are ignored 

1646 """ 

1647 return self.builds.filter(status__in=BuildStatusGroups.ACTIVE_CODES) 

1648 

1649 @property 

1650 def quantity_being_built(self, include_variants: bool = True): 

1651 """Return the current number of parts currently being built. 

1652 

1653 Arguments: 

1654 include_variants: If True, include variants of this part in the calculation 

1655 

1656 Note: This is the total quantity of Build orders, *not* the number of build outputs. 

1657 In this fashion, it is the "projected" quantity of builds 

1658 """ 

1659 builds = BuildModels.Build.objects.filter( 

1660 status__in=BuildStatusGroups.ACTIVE_CODES 

1661 ) 

1662 

1663 if include_variants: 1663 ↛ 1668line 1663 didn't jump to line 1668 because the condition on line 1663 was always true

1664 # If we are including variants, get all parts in the variant tree 

1665 builds = builds.filter(part__in=self.get_descendants(include_self=True)) 

1666 else: 

1667 # Only look at this part 

1668 builds = builds.filter(part=self) 

1669 

1670 quantity = 0 

1671 

1672 for build in builds: 1672 ↛ 1674line 1672 didn't jump to line 1674 because the loop on line 1672 never started

1673 # The remaining items in the build 

1674 quantity += build.remaining 

1675 

1676 return quantity 

1677 

1678 @property 

1679 def quantity_in_production(self, include_variants: bool = True): 

1680 """Quantity of this part currently actively in production. 

1681 

1682 Arguments: 

1683 include_variants: If True, include variants of this part in the calculation 

1684 

1685 Note: This may return a different value to `quantity_being_built` 

1686 """ 

1687 quantity = 0 

1688 

1689 items = StockModels.StockItem.objects.filter( 

1690 is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES 

1691 ) 

1692 

1693 if include_variants: 1693 ↛ 1698line 1693 didn't jump to line 1698 because the condition on line 1693 was always true

1694 # If we are including variants, get all parts in the variant tree 

1695 items = items.filter(part__in=self.get_descendants(include_self=True)) 

1696 else: 

1697 # Only look at this part 

1698 items = items.filter(part=self) 

1699 

1700 for item in items: 1700 ↛ 1702line 1700 didn't jump to line 1702 because the loop on line 1700 never started

1701 # The remaining items in the build 

1702 quantity += item.quantity 

1703 

1704 return quantity 

1705 

1706 def build_order_allocations(self, **kwargs): 

1707 """Return all 'BuildItem' objects which allocate this part to Build objects.""" 

1708 include_variants = kwargs.get('include_variants', True) 

1709 

1710 queryset = BuildModels.BuildItem.objects.all() 

1711 

1712 if include_variants: 1712 ↛ 1716line 1712 didn't jump to line 1716 because the condition on line 1712 was always true

1713 variants = self.get_descendants(include_self=True) 

1714 queryset = queryset.filter(stock_item__part__in=variants) 

1715 else: 

1716 queryset = queryset.filter(stock_item__part=self) 

1717 

1718 return queryset 

1719 

1720 def build_order_allocation_count(self, **kwargs): 

1721 """Return the total amount of this part allocated to build orders.""" 

1722 query = self.build_order_allocations(**kwargs).aggregate( 

1723 total=Coalesce( 

1724 Sum('quantity', output_field=models.DecimalField()), 

1725 0, 

1726 output_field=models.DecimalField(), 

1727 ) 

1728 ) 

1729 

1730 return query['total'] 

1731 

1732 def sales_order_allocations(self, **kwargs): 

1733 """Return all sales-order-allocation objects which allocate this part to a SalesOrder.""" 

1734 include_variants = kwargs.get('include_variants', True) 

1735 

1736 queryset = OrderModels.SalesOrderAllocation.objects.all() 

1737 

1738 if include_variants: 1738 ↛ 1744line 1738 didn't jump to line 1744 because the condition on line 1738 was always true

1739 # Include allocations for all variants 

1740 variants = self.get_descendants(include_self=True) 

1741 queryset = queryset.filter(item__part__in=variants) 

1742 else: 

1743 # Only look at this part 

1744 queryset = queryset.filter(item__part=self) 

1745 

1746 # Default behaviour is to only return *pending* allocations 

1747 pending = kwargs.get('pending', True) 

1748 

1749 if pending is True: 1749 ↛ 1755line 1749 didn't jump to line 1755 because the condition on line 1749 was always true

1750 # Look only for 'open' orders which have not shipped 

1751 queryset = queryset.filter( 

1752 line__order__status__in=SalesOrderStatusGroups.OPEN, 

1753 shipment__shipment_date=None, 

1754 ) 

1755 elif pending is False: 

1756 # Look only for 'closed' orders or orders which have shipped 

1757 queryset = queryset.exclude( 

1758 line__order__status__in=SalesOrderStatusGroups.OPEN, 

1759 shipment__shipment_date=None, 

1760 ) 

1761 

1762 return queryset 

1763 

1764 def sales_order_allocation_count(self, **kwargs): 

1765 """Return the total quantity of this part allocated to sales orders.""" 

1766 query = self.sales_order_allocations(**kwargs).aggregate( 

1767 total=Coalesce( 

1768 Sum('quantity', output_field=models.DecimalField()), 

1769 0, 

1770 output_field=models.DecimalField(), 

1771 ) 

1772 ) 

1773 

1774 return query['total'] 

1775 

1776 def transfer_order_allocations(self, **kwargs): 

1777 """Return all transfer-order-allocation objects which allocate this part to a TransferOrder.""" 

1778 include_variants = kwargs.get('include_variants', True) 

1779 

1780 queryset = OrderModels.TransferOrderAllocation.objects.all() 

1781 

1782 if include_variants: 

1783 # Include allocations for all variants 

1784 variants = self.get_descendants(include_self=True) 

1785 queryset = queryset.filter(item__part__in=variants) 

1786 else: 

1787 # Only look at this part 

1788 queryset = queryset.filter(item__part=self) 

1789 

1790 # Default behaviour is to only return *pending* allocations 

1791 pending = kwargs.get('pending', True) 

1792 

1793 if pending is True: 

1794 # Look only for 'open' orders 

1795 queryset = queryset.filter( 

1796 line__order__status__in=TransferOrderStatusGroups.OPEN 

1797 ) 

1798 elif pending is False: 

1799 # Look only for 'closed' orders 

1800 queryset = queryset.exclude( 

1801 line__order__status__in=TransferOrderStatusGroups.OPEN 

1802 ) 

1803 

1804 return queryset 

1805 

1806 def transfer_order_allocation_count(self, **kwargs): 

1807 """Return the total quantity of this part allocated to transfer orders.""" 

1808 query = self.transfer_order_allocations(**kwargs).aggregate( 

1809 total=Coalesce( 

1810 Sum('quantity', output_field=models.DecimalField()), 

1811 0, 

1812 output_field=models.DecimalField(), 

1813 ) 

1814 ) 

1815 

1816 return query['total'] 

1817 

1818 def allocation_count(self, **kwargs): 

1819 """Return the total quantity of stock allocated for this part, against build orders, sales orders, and transfer orders.""" 

1820 if self.id is None: 1820 ↛ 1822line 1820 didn't jump to line 1822 because the condition on line 1820 was never true

1821 # If this instance has not been saved, foreign-key lookups will fail 

1822 return 0 

1823 

1824 return sum([ 

1825 self.build_order_allocation_count(**kwargs), 

1826 self.sales_order_allocation_count(**kwargs), 

1827 # For now, stock allocated to a transfer order will not impact its availability 

1828 # self.transfer_order_allocation_count(**kwargs), 

1829 ]) 

1830 

1831 def stock_entries( 

1832 self, include_variants=True, include_external=True, in_stock=None, location=None 

1833 ): 

1834 """Return all stock entries for this Part. 

1835 

1836 Arguments: 

1837 include_variants: If True, include stock entries for all part variants 

1838 include_external: If True, include stock entries which are in 'external' locations 

1839 in_stock: If True, filter by stock entries which are 'in stock' 

1840 location: If set, filter by stock entries in the specified location 

1841 """ 

1842 if include_variants: 1842 ↛ 1847line 1842 didn't jump to line 1847 because the condition on line 1842 was always true

1843 query = StockModels.StockItem.objects.filter( 

1844 part__in=self.get_descendants(include_self=True) 

1845 ) 

1846 else: 

1847 query = self.stock_items 

1848 

1849 if in_stock is True: 1849 ↛ 1851line 1849 didn't jump to line 1851 because the condition on line 1849 was always true

1850 query = query.filter(StockModels.StockItem.IN_STOCK_FILTER) 

1851 elif in_stock is False: 

1852 query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER) 

1853 

1854 if include_external is False: 1854 ↛ 1856line 1854 didn't jump to line 1856 because the condition on line 1854 was never true

1855 # Exclude stock entries which are not 'internal' 

1856 query = query.filter(location__external=False) 

1857 

1858 if location: 1858 ↛ 1859line 1858 didn't jump to line 1859 because the condition on line 1858 was never true

1859 locations = location.get_descendants(include_self=True) 

1860 query = query.filter(location__in=locations) 

1861 

1862 return query 

1863 

1864 def get_stock_count(self, include_variants=True): 

1865 """Return the total "in stock" count for this part.""" 

1866 entries = self.stock_entries(in_stock=True, include_variants=include_variants) 

1867 

1868 query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) 

1869 

1870 return query['t'] 

1871 

1872 @property 

1873 def total_stock(self): 

1874 """Return the total stock quantity for this part. 

1875 

1876 - Part may be stored in multiple locations 

1877 - If this part is a "template" (variants exist) then these are counted too 

1878 """ 

1879 return self.get_stock_count(include_variants=True) 

1880 

1881 def get_bom_item_filter( 

1882 self, include_inherited: bool = True, include_virtual: bool = True 

1883 ): 

1884 """Returns a query filter for all BOM items associated with this Part. 

1885 

1886 Arguments: 

1887 include_inherited: If True, include BomItem entries defined for parent parts 

1888 include_virtual: If True, include BomItem entries which are virtual 

1889 

1890 There are some considerations: 

1891 

1892 a) BOM items can be defined against *this* part 

1893 b) BOM items can be inherited from a *parent* part 

1894 

1895 We will construct a filter to grab *all* the BOM items! 

1896 

1897 Note: This does *not* return a queryset, it returns a Q object, 

1898 which can be used by some other query operation! 

1899 Because we want to keep our code DRY! 

1900 """ 

1901 bom_filter = Q(part=self) 

1902 

1903 if include_inherited: 

1904 # We wish to include parent parts 

1905 

1906 parents = self.get_ancestors(include_self=False) 

1907 

1908 # There are parents available 

1909 if parents.exists(): 1909 ↛ 1910line 1909 didn't jump to line 1910 because the condition on line 1909 was never true

1910 parent_filter = Q(part__in=parents, inherited=True) 

1911 

1912 # OR the filters together 

1913 bom_filter |= parent_filter 

1914 

1915 if not include_virtual: 1915 ↛ 1916line 1915 didn't jump to line 1916 because the condition on line 1915 was never true

1916 bom_filter &= Q(sub_part__virtual=False) 

1917 

1918 return bom_filter 

1919 

1920 def get_bom_items( 

1921 self, include_inherited: bool = True, include_virtual: bool = True 

1922 ) -> QuerySet[BomItem]: 

1923 """Return a queryset containing all BOM items for this part. 

1924 

1925 Arguments: 

1926 include_inherited (bool): If set, include BomItem entries defined for parent parts 

1927 include_virtual (bool): If set, include BomItem entries which are virtual parts 

1928 """ 

1929 queryset = BomItem.objects.filter( 

1930 self.get_bom_item_filter( 

1931 include_inherited=include_inherited, include_virtual=include_virtual 

1932 ) 

1933 ) 

1934 

1935 return queryset.prefetch_related('part', 'sub_part') 

1936 

1937 def get_installed_part_options( 

1938 self, include_inherited: bool = True, include_variants: bool = True 

1939 ): 

1940 """Return a set of all Parts which can be "installed" into this part, based on the BOM. 

1941 

1942 Arguments: 

1943 include_inherited (bool): If set, include BomItem entries defined for parent parts 

1944 include_variants (bool): If set, include variant parts for BomItems which allow variants 

1945 """ 

1946 parts = set() 

1947 

1948 for bom_item in self.get_bom_items(include_inherited=include_inherited): 

1949 if include_variants and bom_item.allow_variants: 

1950 for part in bom_item.sub_part.get_descendants(include_self=True): 

1951 parts.add(part) 

1952 else: 

1953 parts.add(bom_item.sub_part) 

1954 

1955 return parts 

1956 

1957 def get_used_in_bom_item_filter( 

1958 self, include_variants=True, include_substitutes=True 

1959 ): 

1960 """Return a BomItem queryset which returns all BomItem instances which refer to *this* part. 

1961 

1962 As the BOM allocation logic is somewhat complicated, there are some considerations: 

1963 

1964 A) This part may be directly specified in a BomItem instance 

1965 B) This part may be a *variant* of a part which is directly specified in a BomItem instance 

1966 C) This part may be a *substitute* for a part which is directly specified in a BomItem instance 

1967 

1968 So we construct a query for each case, and combine them... 

1969 """ 

1970 # Cache all *parent* parts 

1971 try: 

1972 parents = self.get_ancestors(include_self=False) 

1973 except ValueError: 

1974 # If get_ancestors() fails, then this part is not saved yet 

1975 parents = [] 

1976 

1977 # Case A: This part is directly specified in a BomItem (we always use this case) 

1978 query = Q(sub_part=self) 

1979 

1980 if include_variants: 1980 ↛ 1985line 1980 didn't jump to line 1985 because the condition on line 1980 was always true

1981 # Case B: This part is a *variant* of a part which is specified in a BomItem which allows variants 

1982 query |= Q(allow_variants=True, sub_part__in=parents) 

1983 

1984 # Case C: This part is a *substitute* of a part which is directly specified in a BomItem 

1985 if include_substitutes: 1985 ↛ 1991line 1985 didn't jump to line 1991 because the condition on line 1985 was always true

1986 # Grab a list of BomItem substitutes which reference this part 

1987 substitutes = self.substitute_items.all() 

1988 

1989 query |= Q(pk__in=[substitute.bom_item.pk for substitute in substitutes]) 

1990 

1991 return query 

1992 

1993 def get_used_in(self, include_inherited=True, include_substitutes=True): 

1994 """Return a list containing all parts this part is used in. 

1995 

1996 Includes consideration of inherited BOMs 

1997 """ 

1998 # Grab a queryset of all BomItem objects which "require" this part 

1999 bom_items = BomItem.objects.filter( 

2000 self.get_used_in_bom_item_filter(include_substitutes=include_substitutes) 

2001 ) 

2002 

2003 # Iterate through the returned items and construct a set of 

2004 parts = set() 

2005 

2006 for bom_item in bom_items: 2006 ↛ 2007line 2006 didn't jump to line 2007 because the loop on line 2006 never started

2007 if bom_item.part in parts: 

2008 continue 

2009 

2010 parts.add(bom_item.part) 

2011 

2012 # Include inherited BOMs? 

2013 if include_inherited and bom_item.inherited: 

2014 try: 

2015 descendants = bom_item.part.get_descendants(include_self=False) 

2016 except ValueError: 

2017 # This part is not saved yet 

2018 descendants = [] 

2019 

2020 for variant in descendants: 

2021 parts.add(variant) 

2022 

2023 return list(parts) 

2024 

2025 @property 

2026 def has_bom(self): 

2027 """Return True if this Part instance has any BOM items.""" 

2028 return self.get_bom_items().exists() 

2029 

2030 def get_trackable_parts(self): 

2031 """Return a queryset of all trackable parts in the BOM for this part.""" 

2032 queryset = self.get_bom_items() 

2033 queryset = queryset.filter(sub_part__trackable=True) 

2034 

2035 return queryset 

2036 

2037 @property 

2038 def has_trackable_parts(self): 

2039 """Return True if any parts linked in the Bill of Materials are trackable. 

2040 

2041 This is important when building the part. 

2042 """ 

2043 return self.get_trackable_parts().exists() 

2044 

2045 @property 

2046 def bom_count(self): 

2047 """Return the number of items contained in the BOM for this part.""" 

2048 return self.get_bom_items().count() 

2049 

2050 @property 

2051 def used_in_count(self): 

2052 """Return the number of part BOMs that this part appears in.""" 

2053 return len(self.get_used_in()) 

2054 

2055 def get_bom_hash(self): 

2056 """Return a checksum hash for the BOM for this part. 

2057 

2058 Used to determine if the BOM has changed (and needs to be signed off!) 

2059 The hash is calculated by hashing each line item in the BOM. Returns a string representation of a hash object which can be compared with a stored value 

2060 """ 

2061 result_hash = hashlib.md5(str(self.id).encode()) 

2062 

2063 # List *all* BOM items (including inherited ones!) 

2064 bom_items = self.get_bom_items().all().prefetch_related('part', 'sub_part') 

2065 

2066 for item in bom_items: 

2067 result_hash.update(str(item.get_item_hash()).encode()) 

2068 

2069 return str(result_hash.digest()) 

2070 

2071 def is_bom_valid(self) -> bool: 

2072 """Check if the BOM is 'valid'. 

2073 

2074 To be "valid", the part must: 

2075 - Have a stored "bom_checksum" value 

2076 - The stored "bom_checksum" must match the calculated checksum. 

2077 

2078 Returns: 

2079 bool: True if the BOM is valid, False otherwise 

2080 """ 

2081 if not self.bom_checksum or not self.bom_checked_date: 

2082 # If there is no BOM checksum, then the BOM is not valid 

2083 return False 

2084 

2085 return self.get_bom_hash() == self.bom_checksum 

2086 

2087 @transaction.atomic 

2088 def validate_bom(self, user, valid: bool = True): 

2089 """Validate the BOM (mark the BOM as validated by the given User. 

2090 

2091 Arguments: 

2092 user: User who is validating the BOM 

2093 valid: If True, mark the BOM as valid (default=True) 

2094 

2095 - Calculates and stores the hash for the BOM 

2096 - Saves the current date and the checking user 

2097 """ 

2098 # Validate each line item, ignoring inherited ones 

2099 bom_items = self.get_bom_items(include_inherited=False).prefetch_related( 

2100 'part', 'sub_part' 

2101 ) 

2102 

2103 if valid: 2103 ↛ 2104line 2103 didn't jump to line 2104 because the condition on line 2103 was never true

2104 for item in bom_items: 

2105 item.validate_hash(valid=True) 

2106 

2107 self.bom_validated = valid 

2108 self.bom_checksum = self.get_bom_hash() if valid else '' 

2109 self.bom_checked_by = user 

2110 self.bom_checked_date = InvenTree.helpers.current_date() 

2111 

2112 self.save() 

2113 

2114 @transaction.atomic 

2115 def clear_bom(self): 

2116 """Clear the BOM items for the part (delete all BOM lines). 

2117 

2118 Note: Does *NOT* delete inherited BOM items! 

2119 """ 

2120 import part.tasks as part_tasks 

2121 

2122 self.bom_items.all().delete() 

2123 

2124 # Offload task to re-validate the BOM for this assembly 

2125 InvenTree.tasks.offload_task(part_tasks.check_bom_valid, self.pk, group='part') 

2126 

2127 def getRequiredParts(self, recursive=False, parts=None): 

2128 """Return a list of parts required to make this part (i.e. BOM items). 

2129 

2130 Args: 

2131 recursive: If True iterate down through sub-assemblies 

2132 parts: Set of parts already found (to prevent recursion issues) 

2133 """ 

2134 if parts is None: 

2135 parts = set() 

2136 

2137 bom_items = self.get_bom_items() 

2138 

2139 for bom_item in bom_items: 

2140 sub_part = bom_item.sub_part 

2141 

2142 if sub_part not in parts: 

2143 parts.add(sub_part) 

2144 

2145 if recursive: 

2146 sub_part.getRequiredParts(recursive=True, parts=parts) 

2147 

2148 return parts 

2149 

2150 @property 

2151 def supplier_count(self): 

2152 """Return the number of supplier parts available for this part.""" 

2153 return self.supplier_parts.count() 

2154 

2155 def update_pricing(self): 

2156 """Recalculate cached pricing for this Part instance.""" 

2157 self.pricing.update_pricing() 

2158 

2159 @property 

2160 def pricing(self): 

2161 """Return the PartPricing information for this Part instance. 

2162 

2163 If there is no PartPricing database entry defined for this Part, 

2164 it will first be created, and then returned. 

2165 """ 

2166 try: 

2167 pricing = PartPricing.objects.get(part=self) 

2168 except PartPricing.DoesNotExist: 

2169 pricing = PartPricing(part=self) 

2170 

2171 return pricing 

2172 

2173 def schedule_pricing_update( 

2174 self, create: bool = False, force: bool = False, refresh: bool = True 

2175 ): 

2176 """Helper function to schedule a pricing update. 

2177 

2178 Importantly, catches any errors which may occur during deletion of related objects, 

2179 in particular due to post_delete signals. 

2180 

2181 Ref: https://github.com/inventree/InvenTree/pull/3986 

2182 

2183 Arguments: 

2184 create: Whether or not a new PartPricing object should be created if it does not already exist 

2185 force: If True, force the pricing to be updated even auto pricing is disabled 

2186 refresh: If True, refresh the PartPricing object from the database 

2187 """ 

2188 if not force and not get_global_setting( 

2189 'PRICING_AUTO_UPDATE', backup_value=True 

2190 ): 

2191 return 

2192 

2193 if refresh: 

2194 try: 

2195 self.refresh_from_db() 

2196 except Part.DoesNotExist: 

2197 return 

2198 

2199 try: 

2200 pricing = self.pricing 

2201 

2202 if create or pricing.pk: 

2203 pricing.schedule_for_update(refresh=refresh) 

2204 except IntegrityError: 

2205 # If this part instance has been deleted, 

2206 # some post-delete or post-save signals may still be fired 

2207 # which can cause issues down the track 

2208 pass 

2209 

2210 def get_price_info(self, quantity=1, buy=True, bom=True, internal=False): 

2211 """Return a simplified pricing string for this part. 

2212 

2213 Args: 

2214 quantity: Number of units to calculate price for 

2215 buy: Include supplier pricing (default = True) 

2216 bom: Include BOM pricing (default = True) 

2217 internal: Include internal pricing (default = False) 

2218 """ 

2219 price_range = self.get_price_range(quantity, buy, bom, internal) 

2220 

2221 if price_range is None: 

2222 return None 

2223 

2224 min_price, max_price = price_range 

2225 

2226 if min_price == max_price: 

2227 return min_price 

2228 

2229 min_price = normalize(min_price) 

2230 max_price = normalize(max_price) 

2231 

2232 return f'{min_price} - {max_price}' 

2233 

2234 def get_supplier_price_range(self, quantity=1): 

2235 """Return the supplier price range of this part. 

2236 

2237 Actions: 

2238 - Checks if there is any supplier pricing information associated with this Part 

2239 - Iterate through available supplier pricing and select (min, max) 

2240 - Returns tuple of (min, max) 

2241 

2242 Arguments: 

2243 quantity: Quantity at which to calculate price (default=1) 

2244 

2245 Returns: (min, max) tuple or (None, None) if no supplier pricing available 

2246 """ 

2247 min_price = None 

2248 max_price = None 

2249 

2250 for supplier in self.supplier_parts.all(): 

2251 price = supplier.get_price(quantity) 

2252 

2253 if price is None: 

2254 continue 

2255 

2256 if min_price is None or price < min_price: 

2257 min_price = price 

2258 

2259 if max_price is None or price > max_price: 

2260 max_price = price 

2261 

2262 if min_price is None or max_price is None: 

2263 return None 

2264 

2265 min_price = normalize(min_price) 

2266 max_price = normalize(max_price) 

2267 

2268 return (min_price, max_price) 

2269 

2270 def get_bom_price_range(self, quantity=1, internal=False, purchase=False): 

2271 """Return the price range of the BOM for this part. 

2272 

2273 Adds the minimum price for all components in the BOM. 

2274 Note: If the BOM contains items without pricing information, 

2275 these items cannot be included in the BOM! 

2276 """ 

2277 min_price = None 

2278 max_price = None 

2279 

2280 for item in self.get_bom_items().select_related('sub_part'): 

2281 if item.sub_part.pk == self.pk: 

2282 logger.warning('WARNING: BomItem ID %s contains itself in BOM', item.pk) 

2283 continue 

2284 

2285 q = Decimal(quantity) 

2286 i = Decimal(item.quantity) 

2287 

2288 prices = item.sub_part.get_price_range( 

2289 q * i, internal=internal, purchase=purchase 

2290 ) 

2291 

2292 if prices is None: 

2293 continue 

2294 

2295 low, high = prices 

2296 

2297 if min_price is None: 

2298 min_price = 0 

2299 

2300 if max_price is None: 

2301 max_price = 0 

2302 

2303 min_price += low 

2304 max_price += high 

2305 

2306 if min_price is None or max_price is None: 

2307 return None 

2308 

2309 min_price = normalize(min_price) 

2310 max_price = normalize(max_price) 

2311 

2312 return (min_price, max_price) 

2313 

2314 def get_price_range( 

2315 self, quantity=1, buy=True, bom=True, internal=False, purchase=False 

2316 ): 

2317 """Return the price range for this part. 

2318 

2319 This price can be either: 

2320 - Supplier price (if purchased from suppliers) 

2321 - BOM price (if built from other parts) 

2322 - Internal price (if set for the part) 

2323 - Purchase price (if set for the part) 

2324 

2325 Returns: 

2326 Minimum of the supplier, BOM, internal or purchase price. If no pricing available, returns None 

2327 """ 

2328 # only get internal price if set and should be used 

2329 if internal and self.has_internal_price_breaks: 

2330 internal_price = self.get_internal_price(quantity) 

2331 return internal_price, internal_price 

2332 

2333 # only get purchase price if set and should be used 

2334 if purchase: 

2335 purchase_price = self.get_purchase_price(quantity) 

2336 if purchase_price: 

2337 return purchase_price 

2338 

2339 buy_price_range = self.get_supplier_price_range(quantity) if buy else None 

2340 bom_price_range = ( 

2341 self.get_bom_price_range(quantity, internal=internal) if bom else None 

2342 ) 

2343 

2344 if buy_price_range is None: 

2345 return bom_price_range 

2346 

2347 elif bom_price_range is None: 

2348 return buy_price_range 

2349 return ( 

2350 min(buy_price_range[0], bom_price_range[0]), 

2351 max(buy_price_range[1], bom_price_range[1]), 

2352 ) 

2353 

2354 base_cost = models.DecimalField( 

2355 max_digits=19, 

2356 decimal_places=6, 

2357 default=0, 

2358 validators=[MinValueValidator(0)], 

2359 verbose_name=_('base cost'), 

2360 help_text=_('Minimum charge (e.g. stocking fee)'), 

2361 ) 

2362 

2363 multiple = models.PositiveIntegerField( 

2364 default=1, 

2365 validators=[MinValueValidator(1)], 

2366 verbose_name=_('multiple'), 

2367 help_text=_('Sell multiple'), 

2368 ) 

2369 

2370 get_price = common.currency.get_price 

2371 

2372 @property 

2373 def has_price_breaks(self): 

2374 """Return True if this part has sale price breaks.""" 

2375 return self.price_breaks.exists() 

2376 

2377 @property 

2378 def price_breaks(self): 

2379 """Return the associated price breaks in the correct order.""" 

2380 return self.salepricebreaks.order_by('quantity').all() 

2381 

2382 @property 

2383 def unit_pricing(self): 

2384 """Returns the price of this Part at quantity=1.""" 

2385 return self.get_price(1) 

2386 

2387 def add_price_break(self, quantity, price): 

2388 """Create a new price break for this part. 

2389 

2390 Args: 

2391 quantity: Numerical quantity 

2392 price: Must be a Money object 

2393 """ 

2394 # Check if a price break at that quantity already exists... 

2395 if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): 

2396 return 

2397 

2398 PartSellPriceBreak.objects.create(part=self, quantity=quantity, price=price) 

2399 

2400 def get_internal_price(self, quantity, moq=True, multiples=True, currency=None): 

2401 """Return the internal price of this Part at the specified quantity.""" 

2402 return common.currency.get_price( 

2403 self, quantity, moq, multiples, currency, break_name='internal_price_breaks' 

2404 ) 

2405 

2406 @property 

2407 def has_internal_price_breaks(self): 

2408 """Return True if this Part has internal pricing information.""" 

2409 return self.internal_price_breaks.exists() 

2410 

2411 @property 

2412 def internal_price_breaks(self): 

2413 """Return the associated price breaks in the correct order.""" 

2414 return self.internalpricebreaks.order_by('quantity').all() 

2415 

2416 def get_purchase_price(self, quantity): 

2417 """Calculate the purchase price for this part at the specified quantity. 

2418 

2419 - Looks at available supplier pricing data 

2420 - Calculates the price base on the closest price point 

2421 """ 

2422 currency = currency_code_default() 

2423 try: 

2424 prices = [ 

2425 convert_money(item.purchase_price, currency).amount 

2426 for item in self.stock_items.all() 

2427 if item.purchase_price 

2428 ] 

2429 except MissingRate: 

2430 prices = None 

2431 

2432 if prices: 

2433 return min(prices) * quantity, max(prices) * quantity 

2434 

2435 return None 

2436 

2437 @transaction.atomic 

2438 def copy_bom_from(self, other, clear: bool = True, **kwargs): 

2439 """Copy the BOM from another part. 

2440 

2441 Args: 

2442 other: The part to copy the BOM from 

2443 clear (bool, optional): Remove existing BOM items first. Defaults to True. 

2444 """ 

2445 # Ignore if the other part is actually this part? 

2446 if other == self: 

2447 return 

2448 

2449 if clear: 

2450 # Remove existing BOM items 

2451 # Note: Inherited BOM items are *not* deleted! 

2452 self.bom_items.all().delete() 

2453 

2454 # List of "ancestor" parts above this one 

2455 my_ancestors = self.get_ancestors(include_self=False) 

2456 

2457 raise_error = not kwargs.get('skip_invalid', True) 

2458 

2459 include_inherited = kwargs.get('include_inherited', False) 

2460 

2461 # Should substitute parts be duplicated? 

2462 copy_substitutes = kwargs.get('copy_substitutes', True) 

2463 

2464 # Copy existing BOM items from another part 

2465 # Note: Inherited BOM Items will *not* be duplicated!! 

2466 for bom_item in other.get_bom_items(include_inherited=include_inherited).all(): 

2467 # If this part already has a BomItem pointing to the same sub-part, 

2468 # delete that BomItem from this part first! 

2469 

2470 # Ignore invalid BomItem objects 

2471 if not bom_item.part or not bom_item.sub_part: 

2472 continue 

2473 

2474 # Ignore ancestor parts which are inherited 

2475 if bom_item.part in my_ancestors and bom_item.inherited: 

2476 continue 

2477 

2478 # Skip (or throw error) if BomItem is not valid 

2479 if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error): 

2480 continue 

2481 

2482 # Obtain a list of direct substitute parts against this BomItem 

2483 substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item) 

2484 

2485 # Construct a new BOM item 

2486 bom_item.part = self 

2487 bom_item.pk = None 

2488 

2489 bom_item.save() 

2490 bom_item.refresh_from_db() 

2491 

2492 if copy_substitutes: 

2493 for sub in substitutes: 

2494 # Duplicate the substitute (and point to the *new* BomItem object) 

2495 sub.pk = None 

2496 sub.bom_item = bom_item 

2497 sub.save() 

2498 

2499 @transaction.atomic 

2500 def copy_tests_from(self, other: Part, **kwargs) -> None: 

2501 """Copy all test templates from another Part instance. 

2502 

2503 Note: We only copy the direct test templates, not ones inherited from parent parts. 

2504 """ 

2505 templates = [] 

2506 parts = self.get_ancestors(include_self=True) 

2507 

2508 # Prevent tests from being created for non-testable parts 

2509 if not self.testable: 

2510 return 

2511 

2512 for template in other.test_templates.all(): 

2513 # Skip if a test template already exists for this part / key combination 

2514 if PartTestTemplate.objects.filter( 

2515 key=template.key, part__in=parts 

2516 ).exists(): 

2517 continue 

2518 

2519 template.pk = None 

2520 template.part = self 

2521 templates.append(template) 

2522 

2523 if len(templates) > 0: 

2524 PartTestTemplate.objects.bulk_create(templates, batch_size=250) 

2525 

2526 @transaction.atomic 

2527 def copy_category_parameters(self, category: PartCategory): 

2528 """Copy parameter templates from the specified PartCategory. 

2529 

2530 This function is normally called when the Part is first created. 

2531 """ 

2532 from common.models import Parameter 

2533 

2534 categories = category.get_ancestors(include_self=True) 

2535 

2536 category_templates = PartCategoryParameterTemplate.objects.filter( 

2537 category__in=categories 

2538 ).order_by('-category__level') 

2539 

2540 template_ids = set() 

2541 parameters = [] 

2542 content_type = ContentType.objects.get_for_model(Part) 

2543 

2544 for category_template in category_templates: 

2545 # First ensure that the part doesn't have that parameter 

2546 if self.parameters_list.filter( 

2547 template=category_template.template 

2548 ).exists(): 

2549 continue 

2550 

2551 # Ensure we do not create duplicate parameters if multiple categories have the same template 

2552 if category_template.template.pk in template_ids: 

2553 continue 

2554 

2555 template_ids.add(category_template.template.pk) 

2556 

2557 parameters.append( 

2558 Parameter( 

2559 template=category_template.template, 

2560 model_type=content_type, 

2561 model_id=self.pk, 

2562 data=category_template.default_value, 

2563 ) 

2564 ) 

2565 

2566 Parameter.objects.bulk_create(parameters, batch_size=250) 

2567 

2568 def getTestTemplates( 

2569 self, required=None, include_parent: bool = True, enabled=None 

2570 ) -> QuerySet[PartTestTemplate]: 

2571 """Return a list of all test templates associated with this Part. 

2572 

2573 These are used for validation of a StockItem. 

2574 

2575 

2576 Args: 

2577 required (bool, optional): Filter templates by whether they are required. Defaults to None. 

2578 include_parent (bool, optional): Include templates from parent parts. Defaults to True. 

2579 enabled (bool, optional): Filter templates by their enabled status. Defaults to None. 

2580 

2581 Returns: 

2582 QuerySet: A queryset of matching test templates. 

2583 """ 

2584 if include_parent: 

2585 tests = PartTestTemplate.objects.filter( 

2586 part__in=self.get_ancestors(include_self=True) 

2587 ) 

2588 else: 

2589 tests = self.test_templates 

2590 

2591 if required is not None: 

2592 tests = tests.filter(required=required) 

2593 

2594 if enabled is not None: 

2595 tests = tests.filter(enabled=enabled) 

2596 

2597 return tests 

2598 

2599 def getTestTemplateMap(self, **kwargs): 

2600 """Return a map of all test templates associated with this Part.""" 

2601 templates = {} 

2602 

2603 for template in self.getTestTemplates(**kwargs): 

2604 templates[template.key] = template 

2605 

2606 return templates 

2607 

2608 def getRequiredTests(self, include_parent=True, enabled=True): 

2609 """Return the tests which are required by this part. 

2610 

2611 Arguments: 

2612 include_parent: If True, include tests which are defined for parent parts 

2613 enabled: If set (either True or False), filter by template "enabled" status 

2614 """ 

2615 return self.getTestTemplates( 

2616 required=True, enabled=enabled, include_parent=include_parent 

2617 ) 

2618 

2619 def sales_orders(self): 

2620 """Return a list of sales orders which reference this part.""" 

2621 orders = [] 

2622 

2623 for line in self.sales_order_line_items.all().prefetch_related('order'): 

2624 if line.order not in orders: 

2625 orders.append(line.order) 

2626 

2627 return orders 

2628 

2629 def purchase_orders(self): 

2630 """Return a list of purchase orders which reference this part.""" 

2631 orders = [] 

2632 

2633 for part in self.supplier_parts.all().prefetch_related( 

2634 'purchase_order_line_items' 

2635 ): 

2636 for order in part.purchase_orders(): 

2637 if order not in orders: 

2638 orders.append(order) 

2639 

2640 return orders 

2641 

2642 @property 

2643 def on_order(self): 

2644 """Return the total number of items on order for this part. 

2645 

2646 Note that some supplier parts may have a different pack_quantity attribute, 

2647 and this needs to be taken into account! 

2648 """ 

2649 from order.models import PurchaseOrderLineItem 

2650 

2651 quantity = 0 

2652 

2653 # Find all outstanding PurchaseOrderLineItem objects which reference this part 

2654 lines = PurchaseOrderLineItem.objects.filter( 

2655 order__status__in=PurchaseOrderStatusGroups.OPEN, 

2656 part__part_id=self.pk, 

2657 quantity__gt=F('received'), 

2658 ).prefetch_related('part') 

2659 

2660 for line in lines: 2660 ↛ 2661line 2660 didn't jump to line 2661 because the loop on line 2660 never started

2661 remaining = line.quantity - line.received 

2662 

2663 if remaining > 0: 

2664 quantity += line.part.base_quantity(remaining) 

2665 

2666 return quantity 

2667 

2668 @property 

2669 def has_variants(self): 

2670 """Check if this Part object has variants underneath it.""" 

2671 return self.get_all_variants().exists() 

2672 

2673 def get_all_variants(self): 

2674 """Return all Part object which exist as a variant under this part.""" 

2675 return self.get_descendants(include_self=False) 

2676 

2677 @property 

2678 def can_convert(self): 

2679 """Check if this Part can be "converted" to a different variant. 

2680 

2681 It can be converted if: 

2682 a) It has non-virtual variant parts underneath it 

2683 b) It has non-virtual template parts above it 

2684 c) It has non-virtual sibling variants 

2685 """ 

2686 return self.get_conversion_options().exists() 

2687 

2688 def get_conversion_options(self): 

2689 """Return options for converting this part to a "variant" within the same tree. 

2690 

2691 a) Variants underneath this one 

2692 b) Immediate parent 

2693 c) Siblings 

2694 """ 

2695 parts = [] 

2696 

2697 # Child parts 

2698 for child in self.get_descendants(include_self=False): 

2699 parts.append(child) 

2700 

2701 # Immediate parent, and siblings 

2702 if self.variant_of: 

2703 parts.append(self.variant_of) 

2704 

2705 siblings = self.get_siblings(include_self=False) 

2706 

2707 for sib in siblings: 

2708 parts.append(sib) 

2709 

2710 filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts]) 

2711 

2712 # Ensure this part is not in the queryset, somehow 

2713 filtered_parts = filtered_parts.exclude(pk=self.pk) 

2714 

2715 filtered_parts = filtered_parts.filter(active=True, virtual=False) 

2716 

2717 return filtered_parts 

2718 

2719 def get_related_parts(self): 

2720 """Return a set of all related parts for this part.""" 

2721 related_parts = set() 

2722 

2723 related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk) 

2724 

2725 related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk) 

2726 

2727 for related_part in related_parts_1: 

2728 # Add to related parts list 

2729 related_parts.add(related_part.part_2) 

2730 

2731 for related_part in related_parts_2: 

2732 # Add to related parts list 

2733 related_parts.add(related_part.part_1) 

2734 

2735 return related_parts 

2736 

2737 @property 

2738 def related_count(self): 

2739 """Return the number of 'related parts' which point to this Part.""" 

2740 return len(self.get_related_parts()) 

2741 

2742 def is_part_low_on_stock(self): 

2743 """Returns True if the total stock for this part is less than the minimum stock level.""" 

2744 return self.get_stock_count() < self.minimum_stock 

2745 

2746 

2747@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') 

2748def after_save_part(sender, instance: Part, created, **kwargs): 

2749 """Function to be executed after a Part is saved.""" 

2750 from django.conf import settings 

2751 

2752 from part import tasks as part_tasks 

2753 

2754 if instance and not created and not InvenTree.ready.isImportingData(): 

2755 # Check part stock only if we are *updating* the part (not creating it) 

2756 

2757 # Run this check in the background 

2758 InvenTree.tasks.offload_task( 

2759 part_tasks.notify_low_stock_if_required, 

2760 instance.pk, 

2761 group='notification', 

2762 force_async=not settings.TESTING, # Force async unless in testing mode 

2763 ) 

2764 

2765 # Schedule a background task to rebuild any supplier parts 

2766 InvenTree.tasks.offload_task( 

2767 part_tasks.rebuild_supplier_parts, 

2768 instance.pk, 

2769 force_async=True, 

2770 group='part', 

2771 ) 

2772 

2773 

2774class PartPricing(common.models.MetaMixin): 

2775 """Model for caching min/max pricing information for a particular Part. 

2776 

2777 It is prohibitively expensive to calculate min/max pricing for a part "on the fly". 

2778 As min/max pricing does not change very often, we pre-calculate and cache these values. 

2779 

2780 Whenever pricing is updated, these values are re-calculated and stored. 

2781 

2782 Pricing information is cached for: 

2783 

2784 - BOM cost (min / max cost of component items) 

2785 - Purchase cost (based on purchase history) 

2786 - Internal cost (based on user-specified InternalPriceBreak data) 

2787 - Supplier price (based on supplier part data) 

2788 - Variant price (min / max cost of any variants) 

2789 - Overall best / worst (based on the values listed above) 

2790 - Sale price break min / max values 

2791 - Historical sale pricing min / max values 

2792 

2793 Note that this pricing information does not take "quantity" into account: 

2794 - This provides a simple min / max pricing range, which is quite valuable in a lot of situations 

2795 - Quantity pricing still needs to be calculated 

2796 - Quantity pricing can be viewed from the part detail page 

2797 - Detailed pricing information is very context specific in any case 

2798 """ 

2799 

2800 # When calculating assembly pricing, we limit the depth of the calculation 

2801 MAX_PRICING_DEPTH = 50 

2802 

2803 @property 

2804 def is_valid(self): 

2805 """Return True if the cached pricing is valid.""" 

2806 return self.updated is not None 

2807 

2808 def convert(self, money): 

2809 """Attempt to convert money value to default currency. 

2810 

2811 If a MissingRate error is raised, ignore it and return None 

2812 """ 

2813 if money is None: 

2814 return None 

2815 

2816 target_currency = currency_code_default() 

2817 

2818 try: 

2819 result = convert_money(money, target_currency) 

2820 except MissingRate: 

2821 logger.warning( 

2822 'No currency conversion rate available for %s -> %s', 

2823 money.currency, 

2824 target_currency, 

2825 ) 

2826 result = None 

2827 

2828 return result 

2829 

2830 def schedule_for_update(self, counter: int = 0, refresh: bool = True): 

2831 """Schedule this pricing to be updated. 

2832 

2833 Arguments: 

2834 counter: Recursion counter (used to prevent infinite recursion) 

2835 refresh: If specified, the PartPricing object will be refreshed from the database 

2836 """ 

2837 import InvenTree.ready 

2838 

2839 # If importing data, skip pricing update 

2840 if InvenTree.ready.isImportingData(): 

2841 return 

2842 

2843 # If running data migrations, skip pricing update 

2844 if InvenTree.ready.isRunningMigrations(): 

2845 return 

2846 

2847 if ( 

2848 not self.part 

2849 or not self.part.pk 

2850 or not Part.objects.filter(pk=self.part.pk).exists() 

2851 ): 

2852 logger.warning( 

2853 'Referenced part instance does not exist - skipping pricing update.' 

2854 ) 

2855 return 

2856 

2857 try: 

2858 if refresh and self.pk: 

2859 self.refresh_from_db() 

2860 except (PartPricing.DoesNotExist, IntegrityError): 

2861 # Error thrown if this PartPricing instance has already been removed 

2862 logger.warning( 

2863 "Error refreshing PartPricing instance for part '%s'", self.part 

2864 ) 

2865 return 

2866 

2867 # Ensure that the referenced part still exists in the database 

2868 try: 

2869 p = self.part 

2870 if True: # refresh and p.pk: 

2871 p.refresh_from_db() 

2872 except IntegrityError: 

2873 logger.exception( 

2874 "Could not update PartPricing as Part '%s' does not exist", self.part 

2875 ) 

2876 return 

2877 

2878 if self.scheduled_for_update: 

2879 # Ignore if the pricing is already scheduled to be updated 

2880 logger.debug('Pricing for %s already scheduled for update - skipping', p) 

2881 return 

2882 

2883 if counter > self.MAX_PRICING_DEPTH: 

2884 # Prevent infinite recursion / stack depth issues 

2885 logger.debug( 

2886 counter, f'Skipping pricing update for {p} - maximum depth exceeded' 

2887 ) 

2888 return 

2889 

2890 try: 

2891 self.scheduled_for_update = True 

2892 self.save() 

2893 except IntegrityError: 

2894 # An IntegrityError here likely indicates that the referenced part has already been deleted 

2895 logger.exception( 

2896 "Could not save PartPricing for part '%s' to the database", self.part 

2897 ) 

2898 return 

2899 

2900 import part.tasks as part_tasks 

2901 

2902 # Pricing calculations are performed in the background, 

2903 # unless the TESTING_PRICING flag is set 

2904 background = not settings.TESTING or not settings.TESTING_PRICING 

2905 

2906 # Offload task to update the pricing 

2907 # Force async, to prevent running in the foreground (unless in testing mode) 

2908 InvenTree.tasks.offload_task( 

2909 part_tasks.update_part_pricing, 

2910 self, 

2911 counter=counter, 

2912 force_async=background, 

2913 group='pricing', 

2914 ) 

2915 

2916 def update_pricing( 

2917 self, 

2918 counter: int = 0, 

2919 cascade: bool = True, 

2920 previous_min=None, 

2921 previous_max=None, 

2922 ): 

2923 """Recalculate all cost data for the referenced Part instance. 

2924 

2925 Arguments: 

2926 counter: Recursion counter (used to prevent infinite recursion) 

2927 cascade: If True, update pricing for all assemblies and templates which use this part 

2928 previous_min: Previous minimum price (used to prevent further updates if unchanged) 

2929 previous_max: Previous maximum price (used to prevent further updates if unchanged) 

2930 

2931 """ 

2932 # If importing data, skip pricing update 

2933 if InvenTree.ready.isImportingData(): 

2934 return 

2935 

2936 # If running data migrations, skip pricing update 

2937 if InvenTree.ready.isRunningMigrations(): 

2938 return 

2939 

2940 if self.pk is not None: 

2941 try: 

2942 self.refresh_from_db() 

2943 except PartPricing.DoesNotExist: 

2944 pass 

2945 

2946 self.update_bom_cost(save=False) 

2947 self.update_purchase_cost(save=False) 

2948 self.update_internal_cost(save=False) 

2949 self.update_supplier_cost(save=False) 

2950 self.update_variant_cost(save=False) 

2951 self.update_sale_cost(save=False) 

2952 

2953 # Clear scheduling flag 

2954 self.scheduled_for_update = False 

2955 

2956 # Note: save method calls update_overall_cost 

2957 try: 

2958 self.save() 

2959 except IntegrityError: 

2960 # Background worker processes may try to concurrently update 

2961 pass 

2962 

2963 pricing_changed = False 

2964 

2965 # Without previous pricing data, we assume that the pricing has changed 

2966 if previous_min != self.overall_min or previous_max != self.overall_max: 

2967 pricing_changed = True 

2968 

2969 # Update parent assemblies and templates 

2970 if pricing_changed and cascade: 

2971 self.update_assemblies(counter) 

2972 self.update_templates(counter) 

2973 

2974 def update_assemblies(self, counter: int = 0): 

2975 """Schedule updates for any assemblies which use this part.""" 

2976 # If the linked Part is used in any assemblies, schedule a pricing update for those assemblies 

2977 

2978 used_in_parts = self.part.get_used_in() 

2979 

2980 for p in used_in_parts: 

2981 p.pricing.schedule_for_update(counter=counter + 1) 

2982 

2983 def update_templates(self, counter: int = 0): 

2984 """Schedule updates for any template parts above this part.""" 

2985 templates = self.part.get_ancestors(include_self=False) 

2986 

2987 for p in templates: 

2988 p.pricing.schedule_for_update(counter + 1) 

2989 

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

2991 """Whenever pricing model is saved, automatically update overall prices.""" 

2992 # Update the currency which was used to perform the calculation 

2993 self.currency = currency_code_default() 

2994 

2995 try: 

2996 self.update_overall_cost() 

2997 except Exception: 

2998 # If something has happened to the Part model, might throw an error 

2999 pass 

3000 

3001 try: 

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

3003 except Exception: 

3004 # This error may be thrown if there is already duplicate pricing data 

3005 pass 

3006 

3007 def update_bom_cost(self, save=True): 

3008 """Recalculate BOM cost for the referenced Part instance. 

3009 

3010 Iterate through the Bill of Materials, and calculate cumulative pricing: 

3011 

3012 cumulative_min: The sum of minimum costs for each line in the BOM 

3013 cumulative_max: The sum of maximum costs for each line in the BOM 

3014 

3015 Note: The cumulative costs are calculated based on the specified default currency 

3016 """ 

3017 if not self.part.assembly: 

3018 # Not an assembly - no BOM pricing 

3019 self.bom_cost_min = None 

3020 self.bom_cost_max = None 

3021 

3022 if save: 

3023 self.save() 

3024 

3025 # Short circuit - no further operations required 

3026 return 

3027 

3028 currency_code = common.currency.currency_code_default() 

3029 

3030 cumulative_min = Money(0, currency_code) 

3031 cumulative_max = Money(0, currency_code) 

3032 

3033 any_min_elements = False 

3034 any_max_elements = False 

3035 

3036 for bom_item in self.part.get_bom_items(): 

3037 # Loop through each BOM item which is used to assemble this part 

3038 

3039 bom_item_min = None 

3040 bom_item_max = None 

3041 

3042 for sub_part in bom_item.get_valid_parts_for_allocation(): 

3043 # Check each part which *could* be used 

3044 

3045 if sub_part != bom_item.sub_part and not sub_part.active: 

3046 continue 

3047 

3048 sub_part_pricing = sub_part.pricing 

3049 

3050 sub_part_min = self.convert(sub_part_pricing.overall_min) 

3051 sub_part_max = self.convert(sub_part_pricing.overall_max) 

3052 

3053 if sub_part_min is not None: 

3054 if bom_item_min is None or sub_part_min < bom_item_min: 

3055 bom_item_min = sub_part_min 

3056 

3057 if sub_part_max is not None: 

3058 if bom_item_max is None or sub_part_max > bom_item_max: 

3059 bom_item_max = sub_part_max 

3060 

3061 # Update cumulative totals 

3062 if bom_item_min is not None: 

3063 bom_item_min *= bom_item.quantity 

3064 cumulative_min += self.convert(bom_item_min) 

3065 

3066 any_min_elements = True 

3067 

3068 if bom_item_max is not None: 

3069 bom_item_max *= bom_item.quantity 

3070 cumulative_max += self.convert(bom_item_max) 

3071 

3072 any_max_elements = True 

3073 

3074 if any_min_elements: 

3075 self.bom_cost_min = cumulative_min 

3076 else: 

3077 self.bom_cost_min = None 

3078 

3079 if any_max_elements: 

3080 self.bom_cost_max = cumulative_max 

3081 else: 

3082 self.bom_cost_max = None 

3083 

3084 if save: 

3085 self.save() 

3086 

3087 def update_purchase_cost(self, save=True): 

3088 """Recalculate historical purchase cost for the referenced Part instance. 

3089 

3090 Purchase history only takes into account "completed" purchase orders. 

3091 """ 

3092 # Find all line items for completed orders which reference this part 

3093 line_items = OrderModels.PurchaseOrderLineItem.objects.filter( 

3094 order__status=PurchaseOrderStatus.COMPLETE.value, 

3095 received__gt=0, 

3096 part__part=self.part, 

3097 ) 

3098 

3099 # Exclude line items which do not have an associated price 

3100 line_items = line_items.exclude(purchase_price=None) 

3101 

3102 purchase_min = None 

3103 purchase_max = None 

3104 

3105 for line in line_items: 

3106 if line.purchase_price is None: 

3107 continue 

3108 

3109 # Take supplier part pack size into account 

3110 purchase_cost = self.convert( 

3111 line.purchase_price / line.part.pack_quantity_native 

3112 ) 

3113 

3114 if purchase_cost is None: 

3115 continue 

3116 

3117 if purchase_min is None or purchase_cost < purchase_min: 

3118 purchase_min = purchase_cost 

3119 

3120 if purchase_max is None or purchase_cost > purchase_max: 

3121 purchase_max = purchase_cost 

3122 

3123 # Also check if manual stock item pricing is included 

3124 if get_global_setting('PRICING_USE_STOCK_PRICING', True): 

3125 items = self.part.stock_items.all() 

3126 

3127 # Limit to stock items updated within a certain window 

3128 days = int(get_global_setting('PRICING_STOCK_ITEM_AGE_DAYS', 0)) 

3129 

3130 if days > 0: 

3131 date_threshold = InvenTree.helpers.current_date() - timedelta(days=days) 

3132 items = items.filter(updated__gte=date_threshold) 

3133 

3134 for item in items: 

3135 cost = self.convert(item.purchase_price) 

3136 

3137 # Skip if the cost could not be converted (for some reason) 

3138 if cost is None: 

3139 continue 

3140 

3141 if purchase_min is None or cost < purchase_min: 

3142 purchase_min = cost 

3143 

3144 if purchase_max is None or cost > purchase_max: 

3145 purchase_max = cost 

3146 

3147 self.purchase_cost_min = purchase_min 

3148 self.purchase_cost_max = purchase_max 

3149 

3150 if save: 

3151 self.save() 

3152 

3153 def update_internal_cost(self, save=True): 

3154 """Recalculate internal cost for the referenced Part instance.""" 

3155 min_int_cost = None 

3156 max_int_cost = None 

3157 

3158 if get_global_setting('PART_INTERNAL_PRICE', False): 

3159 # Only calculate internal pricing if internal pricing is enabled 

3160 for pb in self.part.internalpricebreaks.all(): 

3161 cost = self.convert(pb.price) 

3162 

3163 if cost is None: 

3164 # Ignore if cost could not be converted for some reason 

3165 continue 

3166 

3167 if min_int_cost is None or cost < min_int_cost: 

3168 min_int_cost = cost 

3169 

3170 if max_int_cost is None or cost > max_int_cost: 

3171 max_int_cost = cost 

3172 

3173 self.internal_cost_min = min_int_cost 

3174 self.internal_cost_max = max_int_cost 

3175 

3176 if save: 

3177 self.save() 

3178 

3179 def update_supplier_cost(self, save=True): 

3180 """Recalculate supplier cost for the referenced Part instance. 

3181 

3182 - The limits are simply the lower and upper bounds of available SupplierPriceBreaks 

3183 - We do not take "quantity" into account here 

3184 """ 

3185 min_sup_cost = None 

3186 max_sup_cost = None 

3187 

3188 if self.part.purchaseable: 

3189 # Iterate through each available SupplierPart instance 

3190 for sp in self.part.supplier_parts.all(): 

3191 # Iterate through each available SupplierPriceBreak instance 

3192 for pb in sp.pricebreaks.all(): 

3193 if pb.price is None: 

3194 continue 

3195 

3196 # Ensure we take supplier part pack size into account 

3197 cost = self.convert(pb.price / sp.pack_quantity_native) 

3198 

3199 if cost is None: 

3200 continue 

3201 

3202 if min_sup_cost is None or cost < min_sup_cost: 

3203 min_sup_cost = cost 

3204 

3205 if max_sup_cost is None or cost > max_sup_cost: 

3206 max_sup_cost = cost 

3207 

3208 self.supplier_price_min = min_sup_cost 

3209 self.supplier_price_max = max_sup_cost 

3210 

3211 if save: 

3212 self.save() 

3213 

3214 def update_variant_cost(self, save=True): 

3215 """Update variant cost values. 

3216 

3217 Here we track the min/max costs of any variant parts. 

3218 """ 

3219 variant_min = None 

3220 variant_max = None 

3221 

3222 active_only = get_global_setting('PRICING_ACTIVE_VARIANTS', False) 

3223 

3224 if self.part.is_template: 

3225 variants = self.part.get_descendants(include_self=False) 

3226 

3227 for v in variants: 

3228 if active_only and not v.active: 

3229 # Ignore inactive variant parts 

3230 continue 

3231 

3232 v_min = self.convert(v.pricing.overall_min) 

3233 v_max = self.convert(v.pricing.overall_max) 

3234 

3235 if v_min is not None: 

3236 if variant_min is None or v_min < variant_min: 

3237 variant_min = v_min 

3238 

3239 if v_max is not None: 

3240 if variant_max is None or v_max > variant_max: 

3241 variant_max = v_max 

3242 

3243 self.variant_cost_min = variant_min 

3244 self.variant_cost_max = variant_max 

3245 

3246 if save: 

3247 self.save() 

3248 

3249 def update_overall_cost(self): 

3250 """Update overall cost values. 

3251 

3252 Here we simply take the minimum / maximum values of the other calculated fields. 

3253 """ 

3254 overall_min = None 

3255 overall_max = None 

3256 

3257 min_costs = [self.bom_cost_min, self.purchase_cost_min, self.internal_cost_min] 

3258 

3259 max_costs = [self.bom_cost_max, self.purchase_cost_max, self.internal_cost_max] 

3260 

3261 purchase_history_override = get_global_setting( 

3262 'PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER', False 

3263 ) 

3264 

3265 if get_global_setting('PRICING_USE_SUPPLIER_PRICING', True): 3265 ↛ 3273line 3265 didn't jump to line 3273 because the condition on line 3265 was always true

3266 # Add supplier pricing data, *unless* historical pricing information should override 

3267 if self.purchase_cost_min is None or not purchase_history_override: 3267 ↛ 3270line 3267 didn't jump to line 3270 because the condition on line 3267 was always true

3268 min_costs.append(self.supplier_price_min) 

3269 

3270 if self.purchase_cost_max is None or not purchase_history_override: 3270 ↛ 3273line 3270 didn't jump to line 3273 because the condition on line 3270 was always true

3271 max_costs.append(self.supplier_price_max) 

3272 

3273 if get_global_setting('PRICING_USE_VARIANT_PRICING', True): 3273 ↛ 3279line 3273 didn't jump to line 3279 because the condition on line 3273 was always true

3274 # Include variant pricing in overall calculations 

3275 min_costs.append(self.variant_cost_min) 

3276 max_costs.append(self.variant_cost_max) 

3277 

3278 # Calculate overall minimum cost 

3279 for cost in min_costs: 

3280 if cost is None: 3280 ↛ 3284line 3280 didn't jump to line 3284 because the condition on line 3280 was always true

3281 continue 

3282 

3283 # Ensure we are working in a common currency 

3284 cost = self.convert(cost) 

3285 

3286 if overall_min is None or cost < overall_min: 

3287 overall_min = cost 

3288 

3289 # Calculate overall maximum cost 

3290 for cost in max_costs: 

3291 if cost is None: 3291 ↛ 3295line 3291 didn't jump to line 3295 because the condition on line 3291 was always true

3292 continue 

3293 

3294 # Ensure we are working in a common currency 

3295 cost = self.convert(cost) 

3296 

3297 if overall_max is None or cost > overall_max: 

3298 overall_max = cost 

3299 

3300 if get_global_setting('PART_BOM_USE_INTERNAL_PRICE', False): 3300 ↛ 3302line 3300 didn't jump to line 3302 because the condition on line 3300 was never true

3301 # Check if internal pricing should override other pricing 

3302 if self.internal_cost_min is not None: 

3303 overall_min = self.internal_cost_min 

3304 

3305 if self.internal_cost_max is not None: 

3306 overall_max = self.internal_cost_max 

3307 

3308 if self.override_min is not None: 3308 ↛ 3309line 3308 didn't jump to line 3309 because the condition on line 3308 was never true

3309 overall_min = self.convert(self.override_min) 

3310 

3311 self.overall_min = overall_min 

3312 

3313 if self.override_max is not None: 3313 ↛ 3314line 3313 didn't jump to line 3314 because the condition on line 3313 was never true

3314 overall_max = self.convert(self.override_max) 

3315 

3316 self.overall_max = overall_max 

3317 

3318 def update_sale_cost(self, save=True): 

3319 """Recalculate sale cost data.""" 

3320 # Iterate through the sell price breaks 

3321 min_sell_price = None 

3322 max_sell_price = None 

3323 

3324 for pb in self.part.salepricebreaks.all(): 

3325 cost = self.convert(pb.price) 

3326 

3327 if cost is None: 

3328 continue 

3329 

3330 if min_sell_price is None or cost < min_sell_price: 

3331 min_sell_price = cost 

3332 

3333 if max_sell_price is None or cost > max_sell_price: 

3334 max_sell_price = cost 

3335 

3336 # Record min/max values 

3337 self.sale_price_min = min_sell_price 

3338 self.sale_price_max = max_sell_price 

3339 

3340 min_sell_history = None 

3341 max_sell_history = None 

3342 

3343 # Calculate sale price history too 

3344 parts = self.part.get_descendants(include_self=True) 

3345 

3346 # Find all line items for shipped sales orders which reference this part 

3347 line_items = OrderModels.SalesOrderLineItem.objects.filter( 

3348 order__status__in=SalesOrderStatusGroups.COMPLETE, part__in=parts 

3349 ) 

3350 

3351 # Exclude line items which do not have associated pricing data 

3352 line_items = line_items.exclude(sale_price=None) 

3353 

3354 for line in line_items: 

3355 cost = self.convert(line.sale_price) 

3356 

3357 if cost is None: 

3358 continue 

3359 

3360 if min_sell_history is None or cost < min_sell_history: 

3361 min_sell_history = cost 

3362 

3363 if max_sell_history is None or cost > max_sell_history: 

3364 max_sell_history = cost 

3365 

3366 self.sale_history_min = min_sell_history 

3367 self.sale_history_max = max_sell_history 

3368 

3369 if save: 

3370 self.save() 

3371 

3372 currency = models.CharField( 

3373 default=currency_code_default, 

3374 max_length=10, 

3375 verbose_name=_('Currency'), 

3376 help_text=_('Currency used to cache pricing calculations'), 

3377 choices=common.currency.currency_code_mappings(), 

3378 ) 

3379 

3380 scheduled_for_update = models.BooleanField(default=False) 

3381 

3382 part = models.OneToOneField( 

3383 Part, 

3384 on_delete=models.CASCADE, 

3385 related_name='pricing_data', 

3386 verbose_name=_('Part'), 

3387 ) 

3388 

3389 bom_cost_min = InvenTree.fields.InvenTreeModelMoneyField( 

3390 null=True, 

3391 blank=True, 

3392 verbose_name=_('Minimum BOM Cost'), 

3393 help_text=_('Minimum cost of component parts'), 

3394 ) 

3395 

3396 bom_cost_max = InvenTree.fields.InvenTreeModelMoneyField( 

3397 null=True, 

3398 blank=True, 

3399 verbose_name=_('Maximum BOM Cost'), 

3400 help_text=_('Maximum cost of component parts'), 

3401 ) 

3402 

3403 purchase_cost_min = InvenTree.fields.InvenTreeModelMoneyField( 

3404 null=True, 

3405 blank=True, 

3406 verbose_name=_('Minimum Purchase Cost'), 

3407 help_text=_('Minimum historical purchase cost'), 

3408 ) 

3409 

3410 purchase_cost_max = InvenTree.fields.InvenTreeModelMoneyField( 

3411 null=True, 

3412 blank=True, 

3413 verbose_name=_('Maximum Purchase Cost'), 

3414 help_text=_('Maximum historical purchase cost'), 

3415 ) 

3416 

3417 internal_cost_min = InvenTree.fields.InvenTreeModelMoneyField( 

3418 null=True, 

3419 blank=True, 

3420 verbose_name=_('Minimum Internal Price'), 

3421 help_text=_('Minimum cost based on internal price breaks'), 

3422 ) 

3423 

3424 internal_cost_max = InvenTree.fields.InvenTreeModelMoneyField( 

3425 null=True, 

3426 blank=True, 

3427 verbose_name=_('Maximum Internal Price'), 

3428 help_text=_('Maximum cost based on internal price breaks'), 

3429 ) 

3430 

3431 supplier_price_min = InvenTree.fields.InvenTreeModelMoneyField( 

3432 null=True, 

3433 blank=True, 

3434 verbose_name=_('Minimum Supplier Price'), 

3435 help_text=_('Minimum price of part from external suppliers'), 

3436 ) 

3437 

3438 supplier_price_max = InvenTree.fields.InvenTreeModelMoneyField( 

3439 null=True, 

3440 blank=True, 

3441 verbose_name=_('Maximum Supplier Price'), 

3442 help_text=_('Maximum price of part from external suppliers'), 

3443 ) 

3444 

3445 variant_cost_min = InvenTree.fields.InvenTreeModelMoneyField( 

3446 null=True, 

3447 blank=True, 

3448 verbose_name=_('Minimum Variant Cost'), 

3449 help_text=_('Calculated minimum cost of variant parts'), 

3450 ) 

3451 

3452 variant_cost_max = InvenTree.fields.InvenTreeModelMoneyField( 

3453 null=True, 

3454 blank=True, 

3455 verbose_name=_('Maximum Variant Cost'), 

3456 help_text=_('Calculated maximum cost of variant parts'), 

3457 ) 

3458 

3459 override_min = InvenTree.fields.InvenTreeModelMoneyField( 

3460 null=True, 

3461 blank=True, 

3462 verbose_name=_('Minimum Cost'), 

3463 help_text=_('Override minimum cost'), 

3464 ) 

3465 

3466 override_max = InvenTree.fields.InvenTreeModelMoneyField( 

3467 null=True, 

3468 blank=True, 

3469 verbose_name=_('Maximum Cost'), 

3470 help_text=_('Override maximum cost'), 

3471 ) 

3472 

3473 overall_min = InvenTree.fields.InvenTreeModelMoneyField( 

3474 null=True, 

3475 blank=True, 

3476 verbose_name=_('Minimum Cost'), 

3477 help_text=_('Calculated overall minimum cost'), 

3478 ) 

3479 

3480 overall_max = InvenTree.fields.InvenTreeModelMoneyField( 

3481 null=True, 

3482 blank=True, 

3483 verbose_name=_('Maximum Cost'), 

3484 help_text=_('Calculated overall maximum cost'), 

3485 ) 

3486 

3487 sale_price_min = InvenTree.fields.InvenTreeModelMoneyField( 

3488 null=True, 

3489 blank=True, 

3490 verbose_name=_('Minimum Sale Price'), 

3491 help_text=_('Minimum sale price based on price breaks'), 

3492 ) 

3493 

3494 sale_price_max = InvenTree.fields.InvenTreeModelMoneyField( 

3495 null=True, 

3496 blank=True, 

3497 verbose_name=_('Maximum Sale Price'), 

3498 help_text=_('Maximum sale price based on price breaks'), 

3499 ) 

3500 

3501 sale_history_min = InvenTree.fields.InvenTreeModelMoneyField( 

3502 null=True, 

3503 blank=True, 

3504 verbose_name=_('Minimum Sale Cost'), 

3505 help_text=_('Minimum historical sale price'), 

3506 ) 

3507 

3508 sale_history_max = InvenTree.fields.InvenTreeModelMoneyField( 

3509 null=True, 

3510 blank=True, 

3511 verbose_name=_('Maximum Sale Cost'), 

3512 help_text=_('Maximum historical sale price'), 

3513 ) 

3514 

3515 

3516class PartStocktake(models.Model): 

3517 """Model representing a 'stock history' entry for a particular Part. 

3518 

3519 A 'stocktake' is a representative count of available stock: 

3520 - Performed on a given date 

3521 - Records quantity of part in stock (across multiple stock items) 

3522 - Records estimated value of "stock on hand" 

3523 """ 

3524 

3525 part = models.ForeignKey( 

3526 Part, 

3527 on_delete=models.CASCADE, 

3528 related_name='stocktakes', 

3529 verbose_name=_('Part'), 

3530 help_text=_('Part for stocktake'), 

3531 ) 

3532 

3533 item_count = models.IntegerField( 

3534 default=1, 

3535 verbose_name=_('Item Count'), 

3536 help_text=_('Number of individual stock entries at time of stocktake'), 

3537 ) 

3538 

3539 quantity = models.DecimalField( 

3540 max_digits=19, 

3541 decimal_places=5, 

3542 validators=[MinValueValidator(0)], 

3543 verbose_name=_('Quantity'), 

3544 help_text=_('Total available stock at time of stocktake'), 

3545 ) 

3546 

3547 date = models.DateField( 

3548 verbose_name=_('Date'), 

3549 help_text=_('Date stocktake was performed'), 

3550 auto_now_add=True, 

3551 ) 

3552 

3553 cost_min = InvenTree.fields.InvenTreeModelMoneyField( 

3554 null=True, 

3555 blank=True, 

3556 verbose_name=_('Minimum Stock Cost'), 

3557 help_text=_('Estimated minimum cost of stock on hand'), 

3558 ) 

3559 

3560 cost_max = InvenTree.fields.InvenTreeModelMoneyField( 

3561 null=True, 

3562 blank=True, 

3563 verbose_name=_('Maximum Stock Cost'), 

3564 help_text=_('Estimated maximum cost of stock on hand'), 

3565 ) 

3566 

3567 

3568class PartSellPriceBreak(common.models.PriceBreak): 

3569 """Represents a price break for selling this part.""" 

3570 

3571 class Meta: 

3572 """Metaclass providing extra model definition.""" 

3573 

3574 verbose_name = _('Part Sale Price Break') 

3575 unique_together = ('part', 'quantity') 

3576 

3577 @staticmethod 

3578 def get_api_url(): 

3579 """Return the list API endpoint URL associated with the PartSellPriceBreak model.""" 

3580 return reverse('api-part-sale-price-list') 

3581 

3582 part = models.ForeignKey( 

3583 Part, 

3584 on_delete=models.CASCADE, 

3585 related_name='salepricebreaks', 

3586 limit_choices_to={'salable': True}, 

3587 verbose_name=_('Part'), 

3588 ) 

3589 

3590 

3591class PartInternalPriceBreak(common.models.PriceBreak): 

3592 """Represents a price break for internally selling this part.""" 

3593 

3594 class Meta: 

3595 """Metaclass providing extra model definition.""" 

3596 

3597 unique_together = ('part', 'quantity') 

3598 

3599 @staticmethod 

3600 def get_api_url(): 

3601 """Return the list API endpoint URL associated with the PartInternalPriceBreak model.""" 

3602 return reverse('api-part-internal-price-list') 

3603 

3604 part = models.ForeignKey( 

3605 Part, 

3606 on_delete=models.CASCADE, 

3607 related_name='internalpricebreaks', 

3608 verbose_name=_('Part'), 

3609 ) 

3610 

3611 

3612class PartStar(models.Model): 

3613 """A PartStar object creates a subscription relationship between a User and a Part. 

3614 

3615 It is used to designate a Part as 'subscribed' for a given User. 

3616 

3617 Attributes: 

3618 part: Link to a Part object 

3619 user: Link to a User object 

3620 """ 

3621 

3622 class Meta: 

3623 """Metaclass providing extra model definition.""" 

3624 

3625 unique_together = ['part', 'user'] 

3626 

3627 part = models.ForeignKey( 

3628 Part, 

3629 on_delete=models.CASCADE, 

3630 verbose_name=_('Part'), 

3631 related_name='starred_users', 

3632 ) 

3633 

3634 user = models.ForeignKey( 

3635 User, 

3636 on_delete=models.CASCADE, 

3637 verbose_name=_('User'), 

3638 related_name='starred_parts', 

3639 ) 

3640 

3641 

3642class PartCategoryStar(models.Model): 

3643 """A PartCategoryStar creates a subscription relationship between a User and a PartCategory. 

3644 

3645 Attributes: 

3646 category: Link to a PartCategory object 

3647 user: Link to a User object 

3648 """ 

3649 

3650 class Meta: 

3651 """Metaclass providing extra model definition.""" 

3652 

3653 unique_together = ['category', 'user'] 

3654 

3655 category = models.ForeignKey( 

3656 PartCategory, 

3657 on_delete=models.CASCADE, 

3658 verbose_name=_('Category'), 

3659 related_name='starred_users', 

3660 ) 

3661 

3662 user = models.ForeignKey( 

3663 User, 

3664 on_delete=models.CASCADE, 

3665 verbose_name=_('User'), 

3666 related_name='starred_categories', 

3667 ) 

3668 

3669 

3670class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): 

3671 """A PartTestTemplate defines a 'template' for a test which is required to be run against a StockItem (an instance of the Part). 

3672 

3673 The test template applies "recursively" to part variants, allowing tests to be 

3674 defined in a hierarchy. 

3675 

3676 Test names are simply strings, rather than enforcing any sort of structure or pattern. 

3677 It is up to the user to determine what tests are defined (and how they are run). 

3678 

3679 To enable generation of unique lookup-keys for each test, there are some validation tests 

3680 run on the model (refer to the validate_unique function). 

3681 """ 

3682 

3683 IMPORT_ID_FIELDS = ['key'] 

3684 

3685 class Meta: 

3686 """Metaclass options for the PartTestTemplate model.""" 

3687 

3688 verbose_name = _('Part Test Template') 

3689 

3690 def __str__(self): 

3691 """Format a string representation of this PartTestTemplate.""" 

3692 return ' | '.join([self.part.name, self.test_name]) 

3693 

3694 @staticmethod 

3695 def get_api_url(): 

3696 """Return the list API endpoint URL associated with the PartTestTemplate model.""" 

3697 return reverse('api-part-test-template-list') 

3698 

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

3700 """Enforce 'clean' operation when saving a PartTestTemplate instance.""" 

3701 self.clean() 

3702 

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

3704 

3705 def clean(self): 

3706 """Clean fields for the PartTestTemplate model.""" 

3707 self.test_name = self.test_name.strip() 

3708 

3709 self.key = helpers.generateTestKey(self.test_name) 

3710 

3711 if len(self.key) == 0: 

3712 raise ValidationError({ 

3713 'test_name': _( 

3714 'Invalid template name - must include at least one alphanumeric character' 

3715 ) 

3716 }) 

3717 

3718 # Check that 'choices' are in fact valid 

3719 if self.choices is None: 

3720 self.choices = '' 

3721 else: 

3722 self.choices = str(self.choices).strip() 

3723 

3724 if self.choices: 

3725 choice_set = set() 

3726 

3727 for choice in self.choices.split(','): 

3728 choice = choice.strip() 

3729 

3730 # Ignore empty choices 

3731 if not choice: 

3732 continue 

3733 

3734 if choice in choice_set: 

3735 raise ValidationError({'choices': _('Choices must be unique')}) 

3736 

3737 choice_set.add(choice) 

3738 

3739 self.validate_unique() 

3740 super().clean() 

3741 

3742 def validate_unique(self, exclude=None): 

3743 """Test that this test template is 'unique' within this part tree.""" 

3744 if not self.part.testable: 

3745 raise ValidationError({ 

3746 'part': _('Test templates can only be created for testable parts') 

3747 }) 

3748 

3749 # Check that this test is unique for this part 

3750 # (including template parts of which this part is a variant) 

3751 parts = self.part.get_ancestors(include_self=True) 

3752 

3753 tests = PartTestTemplate.objects.filter(key=self.key, part__in=parts).exclude( 

3754 pk=self.pk 

3755 ) 

3756 

3757 if tests.exists(): 

3758 raise ValidationError({ 

3759 'test_name': _( 

3760 'Test template with the same key already exists for part' 

3761 ) 

3762 }) 

3763 

3764 super().validate_unique(exclude) 

3765 

3766 part = models.ForeignKey( 

3767 Part, 

3768 on_delete=models.CASCADE, 

3769 related_name='test_templates', 

3770 limit_choices_to={'testable': True}, 

3771 verbose_name=_('Part'), 

3772 ) 

3773 

3774 test_name = models.CharField( 

3775 blank=False, 

3776 max_length=100, 

3777 verbose_name=_('Test Name'), 

3778 help_text=_('Enter a name for the test'), 

3779 ) 

3780 

3781 key = models.CharField( 

3782 blank=True, 

3783 max_length=100, 

3784 verbose_name=_('Test Key'), 

3785 help_text=_('Simplified key for the test'), 

3786 ) 

3787 

3788 description = models.CharField( 

3789 blank=False, 

3790 null=True, 

3791 max_length=100, 

3792 verbose_name=_('Test Description'), 

3793 help_text=_('Enter description for this test'), 

3794 ) 

3795 

3796 enabled = models.BooleanField( 

3797 default=True, verbose_name=_('Enabled'), help_text=_('Is this test enabled?') 

3798 ) 

3799 

3800 required = models.BooleanField( 

3801 default=True, 

3802 verbose_name=_('Required'), 

3803 help_text=_('Is this test required to pass?'), 

3804 ) 

3805 

3806 requires_value = models.BooleanField( 

3807 default=False, 

3808 verbose_name=_('Requires Value'), 

3809 help_text=_('Does this test require a value when adding a test result?'), 

3810 ) 

3811 

3812 requires_attachment = models.BooleanField( 

3813 default=False, 

3814 verbose_name=_('Requires Attachment'), 

3815 help_text=_( 

3816 'Does this test require a file attachment when adding a test result?' 

3817 ), 

3818 ) 

3819 

3820 choices = models.CharField( 

3821 max_length=5000, 

3822 verbose_name=_('Choices'), 

3823 help_text=_('Valid choices for this test (comma-separated)'), 

3824 blank=True, 

3825 ) 

3826 

3827 def get_choices(self): 

3828 """Return a list of valid choices for this test template.""" 

3829 if not self.choices: 

3830 return [] 

3831 

3832 return [x.strip() for x in self.choices.split(',') if x.strip()] 

3833 

3834 

3835class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): 

3836 """A BomItem links a part to its component items. 

3837 

3838 A part can have a BOM (bill of materials) which defines 

3839 which parts are required (and in what quantity) to make it. 

3840 

3841 Attributes: 

3842 part: Link to the parent part (the part that will be produced) 

3843 sub_part: Link to the child part (the part that will be consumed) 

3844 raw_amount: Raw amount of 'sub_part' consumed to produce one 'part' (can be fractional, or use an associated unit) 

3845 quantity: Numerical quantity of 'sub_parts' consumed to produce one 'part' 

3846 optional: Boolean field describing if this BomItem is optional 

3847 consumable: Boolean field describing if this BomItem is considered a 'consumable' 

3848 reference: BOM reference field (e.g. part designators) 

3849 setup_quantity: Extra required quantity for a build, to account for setup losses 

3850 attrition: Estimated losses for a Build, expressed as a percentage (e.g. '2%') 

3851 rounding_multiple: Rounding quantity when calculating the required quantity for a build 

3852 note: Note field for this BOM item 

3853 checksum: Validation checksum for the particular BOM line item 

3854 validated: Boolean field indicating if this BOM item is valid (checksum matches) 

3855 inherited: This BomItem can be inherited by the BOMs of variant parts 

3856 allow_variants: Stock for part variants can be substituted for this BomItem 

3857 """ 

3858 

3859 class Meta: 

3860 """Metaclass providing extra model definition.""" 

3861 

3862 verbose_name = _('BOM Item') 

3863 

3864 def __str__(self): 

3865 """Return a string representation of this BomItem instance.""" 

3866 return f'{decimal2string(self.quantity)} x {self.sub_part.full_name} to make {self.part.full_name}' 

3867 

3868 @staticmethod 

3869 def get_api_url(): 

3870 """Return the list API endpoint URL associated with the BomItem model.""" 

3871 return reverse('api-bom-list') 

3872 

3873 def get_assemblies(self): 

3874 """Return a list of assemblies which use this BomItem.""" 

3875 assemblies = [self.part] 

3876 

3877 if self.inherited: 

3878 assemblies += list(self.part.get_descendants(include_self=False)) 

3879 

3880 return assemblies 

3881 

3882 def get_valid_parts_for_allocation( 

3883 self, 

3884 allow_variants: bool = True, 

3885 allow_substitutes: bool = True, 

3886 allow_inactive: bool = True, 

3887 ): 

3888 """Return a list of valid parts which can be allocated against this BomItem. 

3889 

3890 Arguments: 

3891 allow_variants: If True, include variants of the sub_part 

3892 allow_substitutes: If True, include any directly specified substitute parts 

3893 allow_inactive: If True, include inactive parts in the returned list 

3894 

3895 Includes: 

3896 - The referenced sub_part 

3897 - Any directly specified substitute parts 

3898 - If allow_variants is True, all variants of sub_part 

3899 """ 

3900 # Set of parts we will allow 

3901 parts = set() 

3902 

3903 parts.add(self.sub_part) 

3904 

3905 # Variant parts (if allowed) 

3906 if allow_variants and self.allow_variants: 

3907 for variant in self.sub_part.get_descendants(include_self=False): 

3908 parts.add(variant) 

3909 

3910 # Substitute parts 

3911 if allow_substitutes: 

3912 for sub in self.substitutes.all(): 

3913 parts.add(sub.part) 

3914 

3915 # Account for variants of the substitute part (if allowed) 

3916 if allow_variants and self.allow_variants: 

3917 for sub_variant in sub.part.get_descendants(include_self=False): 

3918 parts.add(sub_variant) 

3919 

3920 valid_parts = [] 

3921 

3922 for p in parts: 

3923 # Trackable status must be the same as the sub_part 

3924 if p.trackable != self.sub_part.trackable: 

3925 continue 

3926 

3927 # Filter by 'active' status 

3928 if not allow_inactive and not p.active: 

3929 continue 

3930 

3931 valid_parts.append(p) 

3932 

3933 return valid_parts 

3934 

3935 def is_stock_item_valid(self, stock_item): 

3936 """Check if the provided StockItem object is "valid" for assignment against this BomItem.""" 

3937 return stock_item.part in self.get_valid_parts_for_allocation() 

3938 

3939 def get_stock_filter(self): 

3940 """Return a queryset filter for selecting StockItems which match this BomItem. 

3941 

3942 - Allow stock from all directly specified substitute parts 

3943 - If allow_variants is True, allow all part variants 

3944 """ 

3945 return Q(part__in=self.get_valid_parts_for_allocation()) 

3946 

3947 def set_quantity(self, quantity: Decimal | str | float): 

3948 """Update the 'quantity' for this BomItem.""" 

3949 self.raw_amount = quantity 

3950 self.recalculate_quantity() 

3951 

3952 def recalculate_quantity(self): 

3953 """Recalculate the 'quantity' field based on the 'raw_amount' field.""" 

3954 if self.raw_amount is None or self.raw_amount == '': 

3955 self.raw_amount = self.quantity 

3956 

3957 # Convert from the "raw amount" to a numerical quantity, using the associated unit (if specified) 

3958 try: 

3959 quantity = InvenTree.conversion.convert_physical_value( 

3960 self.raw_amount, self.sub_part.units, strip_units=False 

3961 ) 

3962 

3963 if not self.sub_part.units and not InvenTree.conversion.is_dimensionless( 

3964 quantity 

3965 ): 

3966 raise ValidationError({ 

3967 'raw_amount': _('Invalid quantity - no units specified for part') 

3968 }) 

3969 

3970 allow_zero_qty = get_global_setting('PART_BOM_ALLOW_ZERO_QUANTITY', False) 

3971 

3972 if allow_zero_qty: 

3973 if float(quantity.magnitude) < 0: 

3974 raise ValidationError({ 

3975 'raw_amount': _( 

3976 'Quantity must be greater than or equal to zero' 

3977 ) 

3978 }) 

3979 

3980 else: 

3981 if float(quantity.magnitude) <= 0: 

3982 raise ValidationError({ 

3983 'raw_amount': _('Quantity must be greater than zero') 

3984 }) 

3985 

3986 # Normalize the quantity, to maximum 5 decimal places 

3987 quantity = Decimal(quantity.magnitude) 

3988 

3989 except ValidationError as e: 

3990 raise ValidationError({'raw_amount': e.messages}) 

3991 

3992 # Ensure that the raw_amount is converted to a Decimal value 

3993 # and quantized to a maximum of 5 decimal places (to avoid floating point issues) 

3994 try: 

3995 self.quantity = Decimal(quantity).quantize( 

3996 Decimal('0.00001'), rounding=ROUND_HALF_UP 

3997 ) 

3998 except InvalidOperation: 

3999 msg = _('Invalid quantity provided') 

4000 raise ValidationError({'quantity': msg, 'raw_amount': msg}) 

4001 

4002 def delete(self): 

4003 """Check if this item can be deleted.""" 

4004 import part.tasks as part_tasks 

4005 

4006 self.check_part_lock(self.part) 

4007 

4008 assemblies = self.get_assemblies() 

4009 super().delete() 

4010 

4011 for assembly in assemblies: 

4012 # Offload task to update the checksum for this assembly 

4013 InvenTree.tasks.offload_task( 

4014 part_tasks.check_bom_valid, assembly.pk, group='part' 

4015 ) 

4016 

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

4018 """Enforce 'clean' operation when saving a BomItem instance.""" 

4019 import part.tasks as part_tasks 

4020 

4021 self.clean() 

4022 

4023 check_lock = kwargs.pop('check_lock', True) 

4024 

4025 if check_lock: 

4026 self.check_part_lock(self.part) 

4027 

4028 db_instance = self.get_db_instance() 

4029 

4030 # Check if the part was changed 

4031 deltas = self.get_field_deltas() 

4032 

4033 if 'part' in deltas and (old_part := deltas['part'].get('old', None)): 

4034 if check_lock: 

4035 self.check_part_lock(old_part) 

4036 

4037 # Update the 'validated' field based on checksum calculation 

4038 self.validated = self.is_line_valid 

4039 

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

4041 

4042 # Do we need to recalculate the BOM hash for assemblies? 

4043 if not db_instance or any(f in deltas for f in self.hash_fields()): 

4044 # If this is a new BomItem, or if any of the fields used to calculate the hash have changed, 

4045 # then we need to recalculate the BOM checksum for all assemblies which use this BomItem 

4046 

4047 assemblies = set() 

4048 

4049 if db_instance: 

4050 # Find all assemblies which use this BomItem *before* we save 

4051 assemblies.update(db_instance.get_assemblies()) 

4052 

4053 # Update the set of assemblies to include those which use this BomItem *after* we save 

4054 assemblies.update(self.get_assemblies()) 

4055 

4056 for assembly in assemblies: 

4057 # Offload task to update the checksum for this assembly 

4058 InvenTree.tasks.offload_task( 

4059 part_tasks.check_bom_valid, assembly.pk, group='part' 

4060 ) 

4061 

4062 def check_part_lock(self, assembly): 

4063 """When editing or deleting a BOM item, check if the assembly is locked. 

4064 

4065 If locked, raise an exception. 

4066 

4067 Arguments: 

4068 assembly: The assembly part 

4069 

4070 Raises: 

4071 ValidationError: If the assembly is locked 

4072 """ 

4073 if not get_global_setting('PART_ENABLE_LOCKING'): 

4074 return 

4075 

4076 if assembly.locked: 

4077 raise ValidationError(_('BOM item cannot be modified - assembly is locked')) 

4078 

4079 # If this BOM item is inherited, check all variants of the assembly 

4080 if self.inherited: 

4081 for part in assembly.get_descendants(include_self=False): 

4082 if part.locked: 

4083 raise ValidationError( 

4084 _('BOM item cannot be modified - variant assembly is locked') 

4085 ) 

4086 

4087 # A link to the parent part 

4088 # Each part will get a reverse lookup field 'bom_items' 

4089 part = models.ForeignKey( 

4090 Part, 

4091 on_delete=models.CASCADE, 

4092 related_name='bom_items', 

4093 verbose_name=_('Part'), 

4094 help_text=_('Select parent part'), 

4095 limit_choices_to={'assembly': True}, 

4096 ) 

4097 

4098 # A link to the child item (sub-part) 

4099 # Each part will get a reverse lookup field 'used_in' 

4100 sub_part = models.ForeignKey( 

4101 Part, 

4102 on_delete=models.CASCADE, 

4103 related_name='used_in', 

4104 verbose_name=_('Sub part'), 

4105 help_text=_('Select part to be used in BOM'), 

4106 limit_choices_to={'component': True}, 

4107 ) 

4108 

4109 raw_amount = models.CharField( 

4110 max_length=25, 

4111 verbose_name=_('Amount'), 

4112 help_text=_('Amount of sub-part consumed to produce one part'), 

4113 blank=False, 

4114 null=False, 

4115 ) 

4116 

4117 # Native quantity required 

4118 quantity = models.DecimalField( 

4119 default=1.0, 

4120 max_digits=15, 

4121 decimal_places=5, 

4122 validators=[MinValueValidator(0)], 

4123 verbose_name=_('Quantity'), 

4124 help_text=_('BOM quantity for this BOM item'), 

4125 ) 

4126 

4127 optional = models.BooleanField( 

4128 default=False, 

4129 verbose_name=_('Optional'), 

4130 help_text=_('This BOM item is optional'), 

4131 ) 

4132 

4133 consumable = models.BooleanField( 

4134 default=False, 

4135 verbose_name=_('Consumable'), 

4136 help_text=_('This BOM item is consumable (it is not tracked in build orders)'), 

4137 ) 

4138 

4139 setup_quantity = models.DecimalField( 

4140 default=0, 

4141 max_digits=15, 

4142 decimal_places=5, 

4143 validators=[MinValueValidator(0)], 

4144 verbose_name=_('Setup Quantity'), 

4145 help_text=_('Extra required quantity for a build, to account for setup losses'), 

4146 ) 

4147 

4148 attrition = models.DecimalField( 

4149 default=0, 

4150 max_digits=6, 

4151 decimal_places=3, 

4152 validators=[MinValueValidator(0), MaxValueValidator(100)], 

4153 verbose_name=_('Attrition'), 

4154 help_text=_( 

4155 'Estimated attrition for a build, expressed as a percentage (0-100)' 

4156 ), 

4157 ) 

4158 

4159 rounding_multiple = models.DecimalField( 

4160 null=True, 

4161 blank=True, 

4162 default=None, 

4163 max_digits=15, 

4164 decimal_places=5, 

4165 validators=[MinValueValidator(0)], 

4166 verbose_name=_('Rounding Multiple'), 

4167 help_text=_( 

4168 'Round up required production quantity to nearest multiple of this value' 

4169 ), 

4170 ) 

4171 

4172 reference = models.CharField( 

4173 max_length=5000, 

4174 blank=True, 

4175 verbose_name=_('Reference'), 

4176 help_text=_('BOM item reference'), 

4177 ) 

4178 

4179 # Note attached to this BOM line item 

4180 note = models.CharField( 

4181 max_length=500, 

4182 blank=True, 

4183 verbose_name=_('Note'), 

4184 help_text=_('BOM item notes'), 

4185 ) 

4186 

4187 checksum = models.CharField( 

4188 max_length=128, 

4189 blank=True, 

4190 verbose_name=_('Checksum'), 

4191 help_text=_('BOM line checksum'), 

4192 ) 

4193 

4194 validated = models.BooleanField( 

4195 default=False, 

4196 verbose_name=_('Validated'), 

4197 help_text=_('This BOM item has been validated'), 

4198 ) 

4199 

4200 inherited = models.BooleanField( 

4201 default=False, 

4202 verbose_name=_('Gets inherited'), 

4203 help_text=_('This BOM item is inherited by BOMs for variant parts'), 

4204 ) 

4205 

4206 allow_variants = models.BooleanField( 

4207 default=False, 

4208 verbose_name=_('Allow Variants'), 

4209 help_text=_('Stock items for variant parts can be used for this BOM item'), 

4210 ) 

4211 

4212 def hash_fields(self) -> list[str]: 

4213 """Return a list of fields to be used for hashing this BOM item. 

4214 

4215 These fields are used to calculate the checksum hash of this BOM item. 

4216 """ 

4217 return [ 

4218 'part', 

4219 'part_id', 

4220 'sub_part', 

4221 'sub_part_id', 

4222 'quantity', 

4223 'setup_quantity', 

4224 'attrition', 

4225 'rounding_multiple', 

4226 'reference', 

4227 'optional', 

4228 'inherited', 

4229 'consumable', 

4230 'allow_variants', 

4231 ] 

4232 

4233 def get_item_hash(self) -> str: 

4234 """Calculate the checksum hash of this BOM line item.""" 

4235 # Seed the hash with the ID of this BOM item 

4236 result_hash = hashlib.md5(b'') 

4237 

4238 for field in self.hash_fields(): 

4239 # Get the value of the field 

4240 value = getattr(self, field, None) 

4241 

4242 # If the value is None, use an empty string 

4243 if value is None: 

4244 value = '' 

4245 

4246 # Normalize decimal values to ensure consistent representation 

4247 # These values are only included if they are non-zero 

4248 # This is to provide some backwards compatibility from before these fields were added 

4249 if value is not None and field in [ 

4250 'quantity', 

4251 'attrition', 

4252 'setup_quantity', 

4253 'rounding_multiple', 

4254 ]: 

4255 try: 

4256 value = normalize(value) 

4257 

4258 if not value or value <= 0: 

4259 continue 

4260 except Exception: 

4261 pass 

4262 

4263 # Update the hash with the string representation of the value 

4264 result_hash.update(str(value).encode()) 

4265 

4266 return str(result_hash.digest()) 

4267 

4268 def validate_hash(self, valid=True): 

4269 """Mark this item as 'valid' (store the checksum hash). 

4270 

4271 Args: 

4272 valid: If true, validate the hash, otherwise invalidate it (default = True) 

4273 """ 

4274 if valid: 

4275 self.checksum = self.get_item_hash() 

4276 else: 

4277 self.checksum = '' 

4278 

4279 # Save the BOM item (bypass lock check) 

4280 self.save(check_lock=False) 

4281 

4282 @property 

4283 def is_line_valid(self): 

4284 """Check if this line item has been validated by the user.""" 

4285 # Ensure an empty checksum returns False 

4286 if len(self.checksum) == 0: 

4287 return False 

4288 

4289 return self.get_item_hash() == self.checksum 

4290 

4291 def clean(self): 

4292 """Check validity of the BomItem model. 

4293 

4294 Performs model checks beyond simple field validation. 

4295 

4296 - A part cannot refer to itself in its BOM 

4297 - A part cannot refer to a part which refers to it 

4298 

4299 - If the "sub_part" is trackable, then the "part" must be trackable too! 

4300 """ 

4301 super().clean() 

4302 

4303 # Recalculate the 'quantity' field based on the 'raw_amount' field 

4304 self.recalculate_quantity() 

4305 

4306 try: 

4307 # Check for circular BOM references 

4308 if self.sub_part: 

4309 self.sub_part.check_add_to_bom(self.part, raise_error=True) 

4310 

4311 # If the sub_part is 'trackable' then the 'quantity' field must be an integer 

4312 if self.sub_part.trackable: 

4313 if self.quantity != int(self.quantity): 

4314 raise ValidationError({ 

4315 'quantity': _( 

4316 'Quantity must be integer value for trackable parts' 

4317 ) 

4318 }) 

4319 

4320 # Force the upstream part to be trackable if the sub_part is trackable 

4321 if not self.part.trackable: 

4322 self.part.trackable = True 

4323 self.part.clean() 

4324 self.part.save() 

4325 else: 

4326 raise ValidationError({'sub_part': _('Sub part must be specified')}) 

4327 except Part.DoesNotExist: 

4328 raise ValidationError({'sub_part': _('Sub part must be specified')}) 

4329 

4330 def can_build_quantity(self, available_stock: float) -> int: 

4331 """Calculate the number of assemblies that can be built with the available stock. 

4332 

4333 Arguments: 

4334 available_stock: The amount of stock available for this BOM item 

4335 

4336 Returns: 

4337 The number of assemblies that can be built with the available stock. 

4338 Returns 0 if the available stock is insufficient. 

4339 """ 

4340 # Account for setup quantity 

4341 available_stock = Decimal(max(0, available_stock - self.setup_quantity)) 

4342 quantity_decimal = Decimal(self.quantity) 

4343 attrition_decimal = Decimal(self.attrition) / 100 

4344 n = quantity_decimal * (1 + attrition_decimal) 

4345 

4346 if n <= 0: 

4347 return 0.0 

4348 

4349 return int(Decimal(available_stock) / n) 

4350 

4351 def get_required_quantity(self, build_quantity: float) -> float: 

4352 """Calculate the required part quantity, based on the supplied build_quantity. 

4353 

4354 Arguments: 

4355 build_quantity: Number of assemblies to build 

4356 

4357 Returns: 

4358 Production quantity required for this component 

4359 """ 

4360 # Base quantity requirement 

4361 required = self.quantity * build_quantity 

4362 

4363 # Account for attrition 

4364 if self.attrition > 0: 

4365 try: 

4366 # Convert attrition percentage to decimal 

4367 attrition = Decimal(self.attrition) / Decimal(100) 

4368 required *= 1 + attrition 

4369 except Exception: 

4370 log_error('bom_item.get_required_quantity') 

4371 

4372 # Account for setup quantity 

4373 if self.setup_quantity > 0: 

4374 try: 

4375 setup_quantity = Decimal(self.setup_quantity) 

4376 required += setup_quantity 

4377 except Exception: 

4378 log_error('bom_item.get_required_quantity') 

4379 

4380 # We now have the total requirement 

4381 # If a "rounding_multiple" is specified, then round up to the nearest multiple 

4382 if self.rounding_multiple and self.rounding_multiple > 0: 

4383 try: 

4384 round_up = Decimal(self.rounding_multiple) 

4385 value = Decimal(required) 

4386 value = math.ceil(value / round_up) * round_up 

4387 required = float(value) 

4388 except InvalidOperation: 

4389 log_error('bom_item.get_required_quantity') 

4390 

4391 return required 

4392 

4393 @property 

4394 def price_range(self, internal=False): 

4395 """Return the price-range for this BOM item.""" 

4396 # get internal price setting 

4397 use_internal = get_global_setting('PART_BOM_USE_INTERNAL_PRICE', False) 

4398 p_range = self.sub_part.get_price_range( 

4399 self.quantity, internal=use_internal and internal 

4400 ) 

4401 

4402 if p_range is None: 

4403 return p_range 

4404 

4405 p_min, p_max = p_range 

4406 

4407 if p_min == p_max: 

4408 return decimal2money(p_min) 

4409 

4410 # Convert to better string representation 

4411 p_min = decimal2money(p_min) 

4412 p_max = decimal2money(p_max) 

4413 

4414 return f'{p_min} to {p_max}' 

4415 

4416 

4417@receiver(post_save, sender=BomItem, dispatch_uid='update_bom_build_lines') 

4418def update_bom_build_lines(sender, instance, created, **kwargs): 

4419 """Update existing build orders when a BomItem is created or edited.""" 

4420 if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): 

4421 import build.tasks 

4422 

4423 InvenTree.tasks.offload_task( 

4424 build.tasks.update_build_order_lines, instance.pk, group='build' 

4425 ) 

4426 

4427 

4428@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item') 

4429@receiver( 

4430 post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break' 

4431) 

4432@receiver( 

4433 post_save, 

4434 sender=PartInternalPriceBreak, 

4435 dispatch_uid='post_save_internal_price_break', 

4436) 

4437def update_pricing_after_edit(sender, instance, created, **kwargs): 

4438 """Callback function when a part price break is created or updated.""" 

4439 # Update part pricing *unless* we are importing data 

4440 if ( 

4441 InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) 

4442 and not InvenTree.ready.isImportingData() 

4443 ): 

4444 if instance.part: 

4445 instance.part.schedule_pricing_update(create=True) 

4446 

4447 

4448@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item') 

4449@receiver( 

4450 post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break' 

4451) 

4452@receiver( 

4453 post_delete, 

4454 sender=PartInternalPriceBreak, 

4455 dispatch_uid='post_delete_internal_price_break', 

4456) 

4457def update_pricing_after_delete(sender, instance, **kwargs): 

4458 """Callback function when a part price break is deleted.""" 

4459 # Update part pricing *unless* we are importing data 

4460 if ( 

4461 InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) 

4462 and not InvenTree.ready.isImportingData() 

4463 ): 

4464 if instance.part: 

4465 instance.part.schedule_pricing_update(create=False) 

4466 

4467 

4468class BomItemSubstitute(InvenTree.models.InvenTreeMetadataModel): 

4469 """A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials. 

4470 

4471 Attributes: 

4472 bom_item: Link to the parent BomItem instance 

4473 part: The part which can be used as a substitute 

4474 """ 

4475 

4476 class Meta: 

4477 """Metaclass providing extra model definition.""" 

4478 

4479 verbose_name = _('BOM Item Substitute') 

4480 

4481 # Prevent duplication of substitute parts 

4482 unique_together = ('part', 'bom_item') 

4483 

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

4485 """Enforce a full_clean when saving the BomItemSubstitute model.""" 

4486 self.full_clean() 

4487 

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

4489 

4490 def validate_unique(self, exclude=None): 

4491 """Ensure that this BomItemSubstitute is "unique". 

4492 

4493 Ensure: 

4494 - It cannot point to the same "part" as the "sub_part" of the parent "bom_item" 

4495 """ 

4496 super().validate_unique(exclude=exclude) 

4497 

4498 if self.part == self.bom_item.sub_part: 

4499 raise ValidationError({ 

4500 'part': _('Substitute part cannot be the same as the master part') 

4501 }) 

4502 

4503 @staticmethod 

4504 def get_api_url(): 

4505 """Returns the list API endpoint URL associated with this model.""" 

4506 return reverse('api-bom-substitute-list') 

4507 

4508 bom_item = models.ForeignKey( 

4509 BomItem, 

4510 on_delete=models.CASCADE, 

4511 related_name='substitutes', 

4512 verbose_name=_('BOM Item'), 

4513 help_text=_('Parent BOM item'), 

4514 ) 

4515 

4516 part = models.ForeignKey( 

4517 Part, 

4518 on_delete=models.CASCADE, 

4519 related_name='substitute_items', 

4520 verbose_name=_('Part'), 

4521 help_text=_('Substitute part'), 

4522 limit_choices_to={'component': True}, 

4523 ) 

4524 

4525 

4526class PartRelated(InvenTree.models.InvenTreeMetadataModel): 

4527 """Store and handle related parts (eg. mating connector, crimps, etc.).""" 

4528 

4529 class Meta: 

4530 """Metaclass defines extra model properties.""" 

4531 

4532 unique_together = ('part_1', 'part_2') 

4533 

4534 part_1 = models.ForeignKey( 

4535 Part, 

4536 related_name='related_parts_1', 

4537 verbose_name=_('Part 1'), 

4538 on_delete=models.CASCADE, 

4539 ) 

4540 

4541 part_2 = models.ForeignKey( 

4542 Part, 

4543 related_name='related_parts_2', 

4544 on_delete=models.CASCADE, 

4545 verbose_name=_('Part 2'), 

4546 help_text=_('Select Related Part'), 

4547 ) 

4548 

4549 note = models.CharField( 

4550 max_length=500, 

4551 blank=True, 

4552 verbose_name=_('Note'), 

4553 help_text=_('Note for this relationship'), 

4554 ) 

4555 

4556 def __str__(self): 

4557 """Return a string representation of this Part-Part relationship.""" 

4558 return f'{self.part_1} <--> {self.part_2}' 

4559 

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

4561 """Enforce a 'clean' operation when saving a PartRelated instance.""" 

4562 self.clean() 

4563 self.validate_unique() 

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

4565 

4566 def clean(self): 

4567 """Overwrite clean method to check that relation is unique.""" 

4568 super().clean() 

4569 

4570 if self.part_1 == self.part_2: 

4571 raise ValidationError( 

4572 _('Part relationship cannot be created between a part and itself') 

4573 ) 

4574 

4575 # Check for inverse relationship 

4576 if PartRelated.objects.filter(part_1=self.part_2, part_2=self.part_1).exists(): 

4577 raise ValidationError(_('Duplicate relationship already exists'))