Coverage for src/backend/InvenTree/stock/models.py: 19%

1235 statements  

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

1"""Stock database model definitions.""" 

2 

3from __future__ import annotations 

4 

5import os 

6from datetime import timedelta 

7from decimal import Decimal, InvalidOperation 

8 

9from django.conf import settings 

10from django.contrib.auth.models import User 

11from django.core.exceptions import ValidationError 

12from django.core.validators import MinValueValidator 

13from django.db import models, transaction 

14from django.db.models import Q, QuerySet, Sum 

15from django.db.models.functions import Coalesce 

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

17from django.db.utils import IntegrityError, OperationalError 

18from django.dispatch import receiver 

19from django.urls import reverse 

20from django.utils.translation import gettext_lazy as _ 

21 

22import structlog 

23from djmoney.contrib.exchange.models import convert_money 

24from mptt.managers import TreeManager 

25from mptt.models import TreeForeignKey 

26 

27import build.models 

28import common.models 

29import InvenTree.exceptions 

30import InvenTree.helpers 

31import InvenTree.models 

32import InvenTree.ready 

33import InvenTree.tasks 

34import order.models 

35import report.mixins 

36import stock.tasks 

37from common.icons import validate_icon 

38from common.settings import get_global_setting 

39from company import models as CompanyModels 

40from generic.enums import StringEnum 

41from generic.states import StatusCodeMixin 

42from generic.states.fields import InvenTreeCustomStatusModelField 

43from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField 

44from InvenTree.status_codes import ( 

45 SalesOrderStatusGroups, 

46 StockHistoryCode, 

47 StockStatus, 

48 StockStatusGroups, 

49) 

50from order.status_codes import TransferOrderStatusGroups 

51from part import models as PartModels 

52from plugin.events import trigger_event 

53from stock.events import StockEvents 

54from stock.generators import generate_batch_code 

55from users.models import Owner 

56 

57logger = structlog.get_logger('inventree') 

58 

59 

60class StockLocationType(InvenTree.models.MetadataMixin, models.Model): 

61 """A type of stock location like Warehouse, room, shelf, drawer. 

62 

63 Attributes: 

64 name: brief name 

65 description: longer form description 

66 icon: icon class 

67 """ 

68 

69 IMPORT_ID_FIELDS = ['name'] 

70 

71 class Meta: 

72 """Metaclass defines extra model properties.""" 

73 

74 verbose_name = _('Stock Location type') 

75 verbose_name_plural = _('Stock Location types') 

76 

77 @staticmethod 

78 def get_api_url(): 

79 """Return API url.""" 

80 return reverse('api-location-type-list') 

81 

82 def __str__(self): 

83 """String representation of a StockLocationType.""" 

84 return self.name 

85 

86 name = models.CharField( 

87 blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Name') 

88 ) 

89 

90 description = models.CharField( 

91 blank=True, 

92 max_length=250, 

93 verbose_name=_('Description'), 

94 help_text=_('Description (optional)'), 

95 ) 

96 

97 icon = models.CharField( 

98 blank=True, 

99 max_length=100, 

100 verbose_name=_('Icon'), 

101 help_text=_('Default icon for all locations that have no icon set (optional)'), 

102 validators=[validate_icon], 

103 ) 

104 

105 

106class StockLocationReportContext(report.mixins.BaseReportContext): 

107 """Report context for the StockLocation model. 

108 

109 Attributes: 

110 location: The StockLocation object itself 

111 qr_data: Formatted QR code data for the StockLocation 

112 parent: The parent StockLocation object 

113 stock_location: The StockLocation object itself (shadow of 'location') 

114 stock_items: Query set of all StockItem objects which are located in the StockLocation 

115 """ 

116 

117 location: StockLocation 

118 qr_data: str 

119 parent: StockLocation | None 

120 stock_location: StockLocation 

121 stock_items: report.mixins.QuerySet[StockItem] 

122 

123 

124class StockLocation( 

125 InvenTree.models.PluginValidationMixin, 

126 InvenTree.models.InvenTreeParameterMixin, 

127 InvenTree.models.InvenTreeBarcodeMixin, 

128 InvenTree.models.InvenTreeTagsMixin, 

129 report.mixins.InvenTreeReportMixin, 

130 InvenTree.models.PathStringMixin, 

131 InvenTree.models.MetadataMixin, 

132 InvenTree.models.InvenTreeTree, 

133): 

134 """Organization tree for StockItem objects. 

135 

136 A "StockLocation" can be considered a warehouse, or storage location 

137 Stock locations can be hierarchical as required 

138 """ 

139 

140 ITEM_PARENT_KEY = 'location' 

141 EXTRA_PATH_FIELDS = ['icon'] 

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

143 

144 objects = TreeManager() 

145 

146 class Meta: 

147 """Metaclass defines extra model properties.""" 

148 

149 verbose_name = _('Stock Location') 

150 verbose_name_plural = _('Stock Locations') 

151 

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

153 """Custom model deletion routine, which updates any child locations or items. 

154 

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

156 """ 

157 super().delete( 

158 delete_children=kwargs.get('delete_sub_locations', False), 

159 delete_items=kwargs.get('delete_stock_items', False), 

160 ) 

161 

162 @staticmethod 

163 def get_api_url(): 

164 """Return API url.""" 

165 return reverse('api-location-list') 

166 

167 @classmethod 

168 def barcode_model_type_code(cls): 

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

170 return 'SL' 

171 

172 def report_context(self) -> StockLocationReportContext: 

173 """Return report context data for this StockLocation.""" 

174 return { 

175 'location': self, 

176 'qr_data': self.barcode, 

177 'parent': self.parent, 

178 'stock_location': self, 

179 'stock_items': self.get_stock_items(), 

180 } 

181 

182 custom_icon = models.CharField( 

183 blank=True, 

184 null=True, 

185 max_length=100, 

186 verbose_name=_('Icon'), 

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

188 db_column='icon', 

189 validators=[validate_icon], 

190 ) 

191 

192 owner = models.ForeignKey( 

193 Owner, 

194 on_delete=models.SET_NULL, 

195 blank=True, 

196 null=True, 

197 verbose_name=_('Owner'), 

198 help_text=_('Select Owner'), 

199 related_name='stock_locations', 

200 ) 

201 

202 structural = models.BooleanField( 

203 default=False, 

204 verbose_name=_('Structural'), 

205 help_text=_( 

206 'Stock items may not be directly located into a structural stock locations, ' 

207 'but may be located to child locations.' 

208 ), 

209 ) 

210 

211 external = models.BooleanField( 

212 default=False, 

213 verbose_name=_('External'), 

214 help_text=_('This is an external stock location'), 

215 ) 

216 

217 location_type = models.ForeignKey( 

218 StockLocationType, 

219 on_delete=models.SET_NULL, 

220 verbose_name=_('Location type'), 

221 related_name='stock_locations', 

222 null=True, 

223 blank=True, 

224 help_text=_('Stock location type of this location'), 

225 ) 

226 

227 @property 

228 def icon(self) -> str: 

229 """Get the current icon used for this location. 

230 

231 The icon field on this model takes precedences over the possibly assigned stock location type 

232 """ 

233 if self.custom_icon: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 return self.custom_icon 

235 

236 if self.location_type: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 return self.location_type.icon 

238 

239 if default_icon := get_global_setting( 239 ↛ 242line 239 didn't jump to line 242 because the condition on line 239 was never true

240 'STOCK_LOCATION_DEFAULT_ICON', cache=True 

241 ): 

242 return default_icon 

243 

244 return '' 

245 

246 @icon.setter 

247 def icon(self, value): 

248 """Setter to keep model API compatibility. 

249 

250 But be careful: 

251 If the field gets loaded as default value by any form which is later saved, 

252 the location no longer inherits its icon from the location type. 

253 """ 

254 self.custom_icon = value 

255 

256 def get_location_owner(self): 

257 """Get the closest "owner" for this location. 

258 

259 Start at this location, and traverse "up" the location tree until we find an owner 

260 """ 

261 for loc in self.get_ancestors(include_self=True, ascending=True): 

262 if loc.owner is not None: 

263 return loc.owner 

264 

265 return None 

266 

267 def check_ownership(self, user): 

268 """Check if the user "owns" (is one of the owners of) the location.""" 

269 # Superuser accounts automatically "own" everything 

270 if user.is_superuser: 

271 return True 

272 

273 ownership_enabled = get_global_setting('STOCK_OWNERSHIP_CONTROL') 

274 

275 if not ownership_enabled: 

276 # Location ownership function is not enabled, so return True 

277 return True 

278 

279 owner = self.get_location_owner() 

280 

281 if owner is None: 

282 # No owner set, for this location or any location above 

283 # So, no ownership checks to perform! 

284 return True 

285 

286 return owner.is_user_allowed(user, include_group=True) 

287 

288 def clean(self): 

289 """Custom clean action for the StockLocation model. 

290 

291 Ensure stock location can't be made structural if stock items already located to them 

292 """ 

293 if self.pk and self.structural and self.stock_item_count(False) > 0: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 raise ValidationError( 

295 _( 

296 'You cannot make this stock location structural because some stock items ' 

297 'are already located into it!' 

298 ) 

299 ) 

300 super().clean() 

301 

302 def get_absolute_url(self): 

303 """Return url for instance.""" 

304 return InvenTree.helpers.pui_url(f'/stock/location/{self.id}') 

305 

306 def get_stock_items(self, cascade=True): 

307 """Return a queryset for all stock items under this category. 

308 

309 Args: 

310 cascade: If True, also look under sublocations (default = True) 

311 """ 

312 if cascade: 

313 query = StockItem.objects.filter( 

314 location__in=self.getUniqueChildren(include_self=True) 

315 ) 

316 else: 

317 query = StockItem.objects.filter(location=self.pk) 

318 

319 return query 

320 

321 def stock_item_count(self, cascade=True): 

322 """Return the number of StockItem objects which live in or under this category.""" 

323 return self.get_stock_items(cascade).count() 

324 

325 @property 

326 def item_count(self): 

327 """Simply returns the number of stock items in this location. 

328 

329 Required for tree view serializer. 

330 """ 

331 return self.stock_item_count() 

332 

333 def get_items(self, cascade=False): 

334 """Return a queryset for all stock items under this category.""" 

335 return self.get_stock_items(cascade=cascade) 

336 

337 

338def default_delete_on_deplete(): 

339 """Return a default value for the 'delete_on_deplete' field. 

340 

341 Prior to 2022-12-24, this field was set to True by default. 

342 Now, there is a user-configurable setting to govern default behavior. 

343 """ 

344 try: 

345 return get_global_setting('STOCK_DELETE_DEPLETED_DEFAULT', True) 

346 except (IntegrityError, OperationalError): 

347 # Revert to original default behavior 

348 return True 

349 

350 

351class StockItemReportContext(report.mixins.BaseReportContext): 

352 """Report context for the StockItem model. 

353 

354 Attributes: 

355 barcode_data: Generated barcode data for the StockItem 

356 barcode_hash: Hash of the barcode data 

357 batch: The batch code for the StockItem 

358 child_items: Query set of all StockItem objects which are children of this StockItem 

359 ipn: The IPN (internal part number) of the associated Part 

360 installed_items: Query set of all StockItem objects which are installed in this StockItem 

361 item: The StockItem object itself 

362 name: The name of the associated Part 

363 part: The Part object which is associated with the StockItem 

364 qr_data: Generated QR code data for the StockItem 

365 qr_url: Generated URL for embedding in a QR code 

366 parameters: Dict object containing the parameters associated with the base Part 

367 quantity: The quantity of the StockItem 

368 result_list: FLattened list of TestResult data associated with the stock item 

369 results: Dict object of TestResult data associated with the StockItem 

370 serial: The serial number of the StockItem 

371 stock_item: The StockItem object itself (shadow of 'item') 

372 tests: Dict object of TestResult data associated with the StockItem (shadow of 'results') 

373 test_keys: List of test keys associated with the StockItem 

374 test_template_list: List of test templates associated with the StockItem 

375 test_templates: Dict object of test templates associated with the StockItem 

376 """ 

377 

378 barcode_data: str 

379 barcode_hash: str 

380 batch: str 

381 child_items: report.mixins.QuerySet[StockItem] 

382 ipn: str | None 

383 installed_items: set[StockItem] 

384 item: StockItem 

385 name: str 

386 part: PartModels.Part 

387 qr_data: str 

388 qr_url: str 

389 parameters: dict[str, str] 

390 quantity: Decimal 

391 result_list: list[StockItemTestResult] 

392 results: dict[str, StockItemTestResult] 

393 serial: str | None 

394 stock_item: StockItem 

395 tests: dict[str, StockItemTestResult] 

396 test_keys: list[str] 

397 test_template_list: report.mixins.QuerySet[PartModels.PartTestTemplate] 

398 test_templates: dict[str, PartModels.PartTestTemplate] 

399 

400 

401class StockSortOrder(StringEnum): 

402 """Enum of ORM sort fields available for stock auto-allocation.""" 

403 

404 DATE_OLDEST = 'updated' 

405 DATE_NEWEST = '-updated' 

406 QUANTITY_ASC = 'quantity' 

407 QUANTITY_DESC = '-quantity' 

408 EXPIRY_SOONEST = 'expiry_date' 

409 

410 

411STOCK_SORT_CHOICES = [ 

412 (StockSortOrder.DATE_OLDEST, _('Oldest stock first (FIFO)')), 

413 (StockSortOrder.DATE_NEWEST, _('Newest stock first (LIFO)')), 

414 (StockSortOrder.QUANTITY_ASC, _('Smallest quantity first')), 

415 (StockSortOrder.QUANTITY_DESC, _('Largest quantity first')), 

416 (StockSortOrder.EXPIRY_SOONEST, _('Soonest expiry date first')), 

417] 

418 

419STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST 

420 

421 

422class StockItem( 

423 InvenTree.models.PluginValidationMixin, 

424 InvenTree.models.InvenTreeAttachmentMixin, 

425 InvenTree.models.InvenTreeBarcodeMixin, 

426 InvenTree.models.InvenTreeNotesMixin, 

427 InvenTree.models.InvenTreeTagsMixin, 

428 StatusCodeMixin, 

429 report.mixins.InvenTreeReportMixin, 

430 common.models.MetaMixin, 

431 InvenTree.models.MetadataMixin, 

432 InvenTree.models.InvenTreeTree, 

433): 

434 """A StockItem object represents a quantity of physical instances of a part. 

435 

436 Attributes: 

437 parent: Link to another StockItem from which this StockItem was created 

438 part: Link to the master abstract part that this StockItem is an instance of 

439 supplier_part: Link to a specific SupplierPart (optional) 

440 location: Where this StockItem is located 

441 quantity: Number of stocked units 

442 batch: Batch number for this StockItem 

443 serial: Unique serial number for this StockItem 

444 link: Optional URL to link to external resource 

445 creation_date: Date that this stock item was created (auto) 

446 updated: Date that the quantity of this stock item was last updated (auto) 

447 expiry_date: Expiry date of the StockItem (optional) 

448 stocktake_date: Date of last stocktake for this item 

449 stocktake_user: User that performed the most recent stocktake 

450 delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero 

451 status: Status of this StockItem (ref: stock.status_codes.StockStatus) 

452 notes: Extra notes field 

453 build: Link to a Build (if this stock item was created from a build) 

454 is_building: Boolean field indicating if this stock item is currently being built (or is "in production") 

455 purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) 

456 sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) 

457 purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier) 

458 packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) 

459 """ 

460 

461 IMPORT_ID_FIELDS = ['serial'] 

462 STATUS_CLASS = StockStatus 

463 

464 class Meta: 

465 """Model meta options.""" 

466 

467 verbose_name = _('Stock Item') 

468 

469 class MPTTMeta: 

470 """MPTT metaclass options.""" 

471 

472 order_insertion_by = ['part'] 

473 

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

475 """Save this StockItem to the database. 

476 

477 Performs a number of checks: 

478 - Unique serial number requirement 

479 - Adds a transaction note when the item is first created. 

480 """ 

481 self.validate_unique() 

482 self.clean() 

483 self.update_serial_number() 

484 

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

486 

487 if user is None: 

488 user = getattr(self, '_user', None) 

489 

490 # If 'add_note = False' specified, then no tracking note will be added for item creation 

491 add_note = kwargs.pop('add_note', True) 

492 

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

494 

495 if self.pk: 

496 # StockItem has already been saved 

497 

498 # Check if "interesting" fields have been changed 

499 # (we wish to record these as historical records) 

500 

501 try: 

502 old = StockItem.objects.get(pk=self.pk) 

503 old_custom_status = old.get_custom_status() 

504 custom_status = self.get_custom_status() 

505 

506 deltas = {} 

507 

508 # Status changed? 

509 if old.status != self.status: 

510 # Custom status changed? 

511 # Matches custom status tracking behavior of StockChangeStatusSerializer 

512 if old_custom_status != custom_status: 

513 deltas['status'] = custom_status 

514 deltas['status_logical'] = self.status 

515 else: 

516 deltas['status'] = self.status 

517 deltas['status_logical'] = self.status 

518 

519 if old_custom_status: 

520 deltas['old_status'] = old_custom_status 

521 deltas['old_status_logical'] = old.status 

522 else: 

523 deltas['old_status'] = old.status 

524 deltas['old_status_logical'] = old.status 

525 

526 if add_note and len(deltas) > 0: 

527 self.add_tracking_entry( 

528 StockHistoryCode.EDITED, user, deltas=deltas, notes=notes 

529 ) 

530 

531 except (ValueError, StockItem.DoesNotExist): 

532 pass 

533 

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

535 

536 # If user information is provided, and no existing note exists, create one! 

537 if add_note and self.tracking_info.count() == 0: 

538 tracking_info = {'status': self.status} 

539 

540 self.add_tracking_entry( 

541 StockHistoryCode.CREATED, 

542 user, 

543 deltas=tracking_info, 

544 notes=notes, 

545 location=self.location, 

546 quantity=float(self.quantity), 

547 ) 

548 

549 def delete(self, ignore_serial_check: bool = False, **kwargs): 

550 """Custom delete method for StockItem model. 

551 

552 Arguments: 

553 ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting 

554 """ 

555 if not ignore_serial_check and not get_global_setting( 

556 'STOCK_ALLOW_DELETE_SERIALIZED', cache=False 

557 ): 

558 if self.serialized: 

559 raise ValidationError(_('Serialized stock items cannot be deleted')) 

560 

561 super().delete(**kwargs) 

562 

563 @staticmethod 

564 def get_api_url(): 

565 """Return API url.""" 

566 return reverse('api-stock-list') 

567 

568 def api_instance_filters(self): 

569 """Custom API instance filters.""" 

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

571 

572 @classmethod 

573 def barcode_model_type_code(cls): 

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

575 return 'SI' 

576 

577 def get_test_keys(self, include_installed=True): 

578 """Construct a flattened list of test 'keys' for this StockItem.""" 

579 keys = [] 

580 

581 for test in self.part.getTestTemplates(required=True): 

582 if test.key not in keys: 

583 keys.append(test.key) 

584 

585 for test in self.part.getTestTemplates(required=False): 

586 if test.key not in keys: 

587 keys.append(test.key) 

588 

589 for result in self.testResultList(include_installed=include_installed): 

590 if result.key not in keys: 

591 keys.append(result.key) 

592 

593 return list(keys) 

594 

595 def report_context(self) -> StockItemReportContext: 

596 """Generate custom report context data for this StockItem.""" 

597 return { 

598 'barcode_data': self.barcode_data, 

599 'barcode_hash': self.barcode_hash, 

600 'batch': self.batch, 

601 'child_items': self.get_children(), 

602 'ipn': self.part.IPN, 

603 'installed_items': self.get_installed_items(cascade=True), 

604 'item': self, 

605 'name': self.part.full_name, 

606 'part': self.part, 

607 'qr_data': self.barcode, 

608 'qr_url': self.get_absolute_url(), 

609 'parameters': self.part.parameters_map(), 

610 'quantity': InvenTree.helpers.normalize(self.quantity), 

611 'result_list': self.testResultList(include_installed=True), 

612 'results': self.testResultMap(include_installed=True, cascade=True), 

613 'serial': self.serial, 

614 'stock_item': self, 

615 'tests': self.testResultMap(), 

616 'test_keys': self.get_test_keys(), 

617 'test_template_list': self.part.getTestTemplates(), 

618 'test_templates': self.part.getTestTemplateMap(), 

619 } 

620 

621 # A Query filter which will be reused in multiple places to determine if a StockItem is actually "in stock" 

622 # See also: StockItem.in_stock() method 

623 IN_STOCK_FILTER = Q( 

624 quantity__gt=0, 

625 sales_order=None, 

626 belongs_to=None, 

627 customer=None, 

628 consumed_by=None, 

629 is_building=False, 

630 status__in=StockStatusGroups.AVAILABLE_CODES, 

631 ) 

632 

633 @classmethod 

634 def get_expired_filter(cls): 

635 """A query filter which can be used to filter StockItem objects which have expired.""" 

636 return ( 

637 cls.IN_STOCK_FILTER 

638 & ~Q(expiry_date=None) 

639 & Q(expiry_date__lt=InvenTree.helpers.current_date()) 

640 ) 

641 

642 @classmethod 

643 def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet: 

644 """Create multiple stock items with the provided serial numbers. 

645 

646 Arguments: 

647 serials: List of serial numbers to create 

648 **kwargs: Additional keyword arguments to pass to the StockItem creation function 

649 

650 Returns: 

651 QuerySet: The created StockItem objects 

652 

653 Raises: 

654 ValidationError: If any of the provided serial numbers are invalid 

655 

656 This method uses bulk_create to create multiple StockItem objects in a single query, 

657 which is much more efficient than creating them one-by-one. 

658 

659 However, it does not perform any validation checks on the provided serial numbers, 

660 and also does not generate any "stock tracking entries". 

661 

662 Note: This is an 'internal' function and should not be used by external code / plugins. 

663 """ 

664 # Ensure the primary-key field is not provided 

665 kwargs.pop('id', None) 

666 kwargs.pop('pk', None) 

667 

668 # Create a list of StockItem objects 

669 items = [] 

670 

671 # Provide some default field values 

672 data = {**kwargs} 

673 

674 # Extract foreign-key fields from the provided data 

675 fk_relations = { 

676 'parent': StockItem, 

677 'part': PartModels.Part, 

678 'build': build.models.Build, 

679 'purchase_order': order.models.PurchaseOrder, 

680 'supplier_part': CompanyModels.SupplierPart, 

681 'location': StockLocation, 

682 'belongs_to': StockItem, 

683 'customer': CompanyModels.Company, 

684 'consumed_by': build.models.Build, 

685 'sales_order': order.models.SalesOrder, 

686 } 

687 

688 for field, model in fk_relations.items(): 

689 if instance_id := data.pop(f'{field}_id', None): 

690 try: 

691 instance = model.objects.get(pk=instance_id) 

692 data[field] = instance 

693 except (ValueError, model.DoesNotExist): 

694 raise ValidationError({field: _(f'{field} does not exist')}) 

695 

696 # Remove some fields which we do not want copied across 

697 for field in [ 

698 'barcode_data', 

699 'barcode_hash', 

700 'stocktake_date', 

701 'stocktake_user', 

702 'stocktake_user_id', 

703 ]: 

704 data.pop(field, None) 

705 

706 if 'part' not in data: 

707 raise ValidationError({'part': _('Part must be specified')}) 

708 

709 part = data['part'] 

710 

711 parent = kwargs.pop('parent', None) or data.get('parent') 

712 tree_id = kwargs.pop('tree_id', StockItem.getNextTreeID()) 

713 

714 if parent: 

715 # Override with parent's tree_id if provided 

716 tree_id = parent.tree_id 

717 

718 # Pre-calculate MPTT fields 

719 data['parent'] = parent if parent else None 

720 data['level'] = parent.level + 1 if parent else 0 

721 data['lft'] = 0 if parent else 1 

722 data['rght'] = 0 if parent else 2 

723 

724 # Force single quantity for each item 

725 data['quantity'] = 1 

726 

727 for serial in serials: 

728 data['serial'] = serial 

729 

730 if serial is not None: 

731 data['serial_int'] = StockItem.convert_serial_to_int(serial) or 0 

732 else: 

733 data['serial_int'] = 0 

734 

735 data['tree_id'] = tree_id 

736 

737 if not parent: 

738 # No parent, this is a top-level item, so increment the tree_id 

739 # This is because each new item is a "top-level" node in the StockItem tree 

740 tree_id += 1 

741 

742 # Construct a new StockItem from the provided dict 

743 items.append(StockItem(**data)) 

744 

745 # Create the StockItem objects in bulk 

746 StockItem.objects.bulk_create(items, batch_size=250) 

747 

748 # We will need to rebuild the stock item tree manually, due to the bulk_create operation 

749 if parent and parent.tree_id: 

750 # Rebuild the tree structure for this StockItem tree 

751 logger.info( 

752 'Rebuilding StockItem tree structure for tree_id: %s', parent.tree_id 

753 ) 

754 stock.tasks.rebuild_stock_item_tree(parent.tree_id) 

755 

756 # Fetch the new StockItem objects from the database 

757 items = StockItem.objects.filter(part=part, serial__in=serials) 

758 

759 # Trigger a 'created' event for the new items 

760 # Note that instead of a single event for each item, 

761 # we trigger a single event for all items created 

762 stock_ids = list(items.values_list('id', flat=True).distinct()) 

763 trigger_event(StockEvents.ITEMS_CREATED, ids=stock_ids) 

764 

765 # Return the newly created StockItem objects 

766 return items 

767 

768 @staticmethod 

769 def convert_serial_to_int(serial: str) -> int | None: 

770 """Convert the provided serial number to an integer value. 

771 

772 This function hooks into the plugin system to allow for custom serial number conversion. 

773 """ 

774 from plugin import PluginMixinEnum, registry 

775 

776 # First, let any plugins convert this serial number to an integer value 

777 # If a non-null value is returned (by any plugin) we will use that 

778 if not InvenTree.ready.isReadOnlyCommand(): 

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

780 try: 

781 serial_int = plugin.convert_serial_to_int(serial) 

782 except Exception: 

783 InvenTree.exceptions.log_error( 

784 'convert_serial_to_int', plugin=plugin.slug 

785 ) 

786 serial_int = None 

787 

788 # Save the first returned result 

789 if serial_int is not None: 

790 # Ensure that it is clipped within a range allowed in the database schema 

791 clip = 0x7FFFFFFF 

792 serial_int = abs(serial_int) 

793 serial_int = min(serial_int, clip) 

794 # Return the first non-null value 

795 return serial_int 

796 

797 # None of the plugins provided a valid integer value 

798 if serial not in [None, '']: 

799 return InvenTree.helpers.extract_int(serial) 

800 else: 

801 return None 

802 

803 def update_serial_number(self): 

804 """Update the 'serial_int' field, to be an integer representation of the serial number. 

805 

806 This is used for efficient numerical sorting 

807 """ 

808 serial = str(getattr(self, 'serial', '')).strip() 

809 

810 if not serial: 

811 self.serial_int = 0 

812 return 

813 

814 serial_int = self.convert_serial_to_int(serial) 

815 

816 try: 

817 serial_int = int(serial_int) 

818 

819 if serial_int <= 0: 

820 serial_int = 0 

821 except (ValueError, TypeError): 

822 serial_int = 0 

823 

824 self.serial_int = serial_int 

825 

826 def get_next_serialized_item(self, include_variants=True, reverse=False): 

827 """Get the "next" serial number for the part this stock item references. 

828 

829 e.g. if this stock item has a serial number 100, we may return the stock item with serial number 101 

830 

831 Note that this only works for "serialized" stock items with integer values 

832 

833 Args: 

834 include_variants: True if we wish to include stock for variant parts 

835 reverse: True if we want to return the "previous" (lower) serial number 

836 

837 Returns: 

838 A StockItem object matching the requirements, or None 

839 """ 

840 if not self.serialized: 

841 return None 

842 

843 # Find only serialized stock items 

844 items = StockItem.objects.exclude(serial=None).exclude(serial='') 

845 

846 if include_variants: 

847 # Match against any part within the variant tree 

848 items = items.filter(part__tree_id=self.part.tree_id) 

849 else: 

850 # Match only against the specific part 

851 items = items.filter(part=self.part) 

852 

853 serial = self.serial_int 

854 

855 if reverse: 

856 # Select only stock items with lower serial numbers, in decreasing order 

857 items = items.filter(serial_int__lt=serial) 

858 items = items.order_by('-serial_int') 

859 else: 

860 # Select only stock items with higher serial numbers, in increasing order 

861 items = items.filter(serial_int__gt=serial) 

862 items = items.order_by('serial_int') 

863 

864 if items.count() > 0: 

865 item = items.first() 

866 

867 if item.serialized: 

868 return item 

869 

870 return None 

871 

872 @property 

873 def get_next_stock_item(self): 

874 """Return the 'next' stock item (based on serial number).""" 

875 return self.get_next_serialized_item() 

876 

877 @property 

878 def get_previous_stock_item(self): 

879 """Return the 'previous' stock item (based on serial number).""" 

880 return self.get_next_serialized_item(reverse=True) 

881 

882 @property 

883 def status_label(self): 

884 """Return label.""" 

885 return StockStatus.label(self.status) 

886 

887 @property 

888 def serialized(self): 

889 """Return True if this StockItem is serialized.""" 

890 return ( 

891 self.serial is not None 

892 and len(str(self.serial).strip()) > 0 

893 and self.quantity == 1 

894 ) 

895 

896 def validate_unique(self, exclude=None): 

897 """Test that this StockItem is "unique". 

898 

899 If the StockItem is serialized, the same serial number. 

900 cannot exist for the same part (or part tree). 

901 """ 

902 super().validate_unique(exclude) 

903 

904 # If the serial number is set, make sure it is not a duplicate 

905 if self.serial: 

906 self.serial = str(self.serial).strip() 

907 

908 try: 

909 self.part.validate_serial_number(self.serial, self, raise_error=True) 

910 except ValidationError as exc: 

911 raise ValidationError({'serial': exc.message}) 

912 

913 def validate_batch_code(self): 

914 """Ensure that the batch code is valid for this StockItem. 

915 

916 - Validation is performed by custom plugins. 

917 - By default, no validation checks are performed 

918 """ 

919 from plugin import PluginMixinEnum, registry 

920 

921 if not InvenTree.ready.isReadOnlyCommand(): 

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

923 try: 

924 plugin.validate_batch_code(self.batch, self) 

925 except ValidationError as exc: 

926 raise ValidationError({'batch': exc.message}) 

927 except Exception: 

928 InvenTree.exceptions.log_error( 

929 'validate_batch_code', plugin=plugin.slug 

930 ) 

931 

932 def clean(self): 

933 """Validate the StockItem object (separate to field validation). 

934 

935 The following validation checks are performed: 

936 - The 'part' and 'supplier_part.part' fields cannot point to the same Part object 

937 - The 'part' is not virtual 

938 - The 'part' does not belong to itself 

939 - The location is not structural 

940 - Quantity must be 1 if the StockItem has a serial number 

941 """ 

942 if self.location is not None and self.location.structural: 

943 raise ValidationError({ 

944 'location': _( 

945 'Stock items cannot be located into structural stock locations!' 

946 ) 

947 }) 

948 

949 super().clean() 

950 

951 # Strip serial number field 

952 if type(self.serial) is str: 

953 self.serial = self.serial.strip() 

954 

955 # Strip batch code field 

956 if type(self.batch) is str: 

957 self.batch = self.batch.strip() 

958 

959 if not get_global_setting('STOCK_ALLOW_EDIT_SERIAL'): 

960 deltas = self.get_field_deltas() 

961 

962 # Prevent editing of serial numbers if the item already has a serial number assigned 

963 if 'serial' in deltas and deltas['serial']['old'] not in [None, '']: 

964 raise ValidationError({ 

965 'serial': _( 

966 'Editing of serial numbers is not allowed - this item has already been assigned a serial number' 

967 ) 

968 }) 

969 

970 # Custom validation of batch code 

971 self.validate_batch_code() 

972 

973 try: 

974 # Trackable parts must have integer values for quantity field! 

975 if self.part.trackable and self.quantity != int(self.quantity): 

976 raise ValidationError({ 

977 'quantity': _('Quantity must be integer value for trackable parts') 

978 }) 

979 

980 # Virtual parts cannot have stock items created against them 

981 if self.part.virtual: 

982 raise ValidationError({ 

983 'part': _('Stock item cannot be created for virtual parts') 

984 }) 

985 except PartModels.Part.DoesNotExist: 

986 # For some reason the 'clean' process sometimes throws errors because self.part does not exist 

987 # It *seems* that this only occurs in unit testing, though. 

988 # Probably should investigate this at some point. 

989 pass 

990 

991 if self.quantity < 0: 

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

993 

994 # The 'supplier_part' field must point to the same part! 

995 try: 

996 if self.supplier_part is not None: 

997 if self.supplier_part.part != self.part: 

998 raise ValidationError({ 

999 'supplier_part': _( 

1000 f"Part type ('{self.supplier_part.part}') must be {self.part}" 

1001 ) 

1002 }) 

1003 

1004 if self.part is not None: 

1005 # A part with a serial number MUST have the quantity set to 1 

1006 if self.serial: 

1007 if self.quantity > 1: 

1008 raise ValidationError({ 

1009 'quantity': _( 

1010 'Quantity must be 1 for item with a serial number' 

1011 ), 

1012 'serial': _( 

1013 'Serial number cannot be set if quantity greater than 1' 

1014 ), 

1015 }) 

1016 

1017 if self.quantity == 0: 

1018 self.quantity = 1 

1019 

1020 elif self.quantity > 1: 

1021 raise ValidationError({ 

1022 'quantity': _( 

1023 'Quantity must be 1 for item with a serial number' 

1024 ) 

1025 }) 

1026 

1027 # Serial numbered items cannot be deleted on depletion 

1028 self.delete_on_deplete = False 

1029 

1030 except PartModels.Part.DoesNotExist: 

1031 pass 

1032 

1033 # Ensure that the item cannot be assigned to itself 

1034 if self.belongs_to and self.belongs_to.pk == self.pk: 

1035 raise ValidationError({'belongs_to': _('Item cannot belong to itself')}) 

1036 

1037 # If the item is marked as "is_building", it must point to a build! 

1038 if self.is_building and not self.build: 

1039 raise ValidationError({ 

1040 'build': _('Item must have a build reference if is_building=True') 

1041 }) 

1042 

1043 # If the item points to a build, check that the Part references match 

1044 if self.build: 

1045 if self.part == self.build.part: 

1046 # Part references match exactly 

1047 pass 

1048 elif self.part in self.build.part.get_conversion_options(): 

1049 # Part reference is one of the valid conversion options for the build output 

1050 pass 

1051 else: 

1052 raise ValidationError({ 

1053 'build': _('Build reference does not point to the same part object') 

1054 }) 

1055 

1056 def get_absolute_url(self): 

1057 """Return url for instance.""" 

1058 return InvenTree.helpers.pui_url(f'/stock/item/{self.id}') 

1059 

1060 def get_part_name(self): 

1061 """Returns part name.""" 

1062 return self.part.full_name 

1063 

1064 # Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship 

1065 parent = TreeForeignKey( 

1066 'self', 

1067 verbose_name=_('Parent Stock Item'), 

1068 on_delete=models.DO_NOTHING, 

1069 blank=True, 

1070 null=True, 

1071 related_name='children', 

1072 ) 

1073 

1074 part = models.ForeignKey( 

1075 'part.Part', 

1076 on_delete=models.CASCADE, 

1077 verbose_name=_('Base Part'), 

1078 related_name='stock_items', 

1079 help_text=_('Base part'), 

1080 limit_choices_to={'virtual': False}, 

1081 ) 

1082 

1083 supplier_part = models.ForeignKey( 

1084 'company.SupplierPart', 

1085 blank=True, 

1086 null=True, 

1087 on_delete=models.SET_NULL, 

1088 verbose_name=_('Supplier Part'), 

1089 help_text=_('Select a matching supplier part for this stock item'), 

1090 related_name='stock_items', 

1091 ) 

1092 

1093 # Note: When a StockLocation is deleted, stock items are updated via a signal 

1094 location = TreeForeignKey( 

1095 StockLocation, 

1096 on_delete=models.DO_NOTHING, 

1097 verbose_name=_('Stock Location'), 

1098 related_name='stock_items', 

1099 blank=True, 

1100 null=True, 

1101 help_text=_('Where is this stock item located?'), 

1102 ) 

1103 

1104 packaging = models.CharField( 

1105 max_length=50, 

1106 blank=True, 

1107 null=True, 

1108 verbose_name=_('Packaging'), 

1109 help_text=_('Packaging this stock item is stored in'), 

1110 ) 

1111 

1112 # When deleting a stock item with installed items, those installed items are also installed 

1113 belongs_to = models.ForeignKey( 

1114 'self', 

1115 verbose_name=_('Installed In'), 

1116 on_delete=models.CASCADE, 

1117 related_name='installed_parts', 

1118 blank=True, 

1119 null=True, 

1120 help_text=_('Is this item installed in another item?'), 

1121 ) 

1122 

1123 customer = models.ForeignKey( 

1124 CompanyModels.Company, 

1125 on_delete=models.SET_NULL, 

1126 null=True, 

1127 blank=True, 

1128 limit_choices_to={'is_customer': True}, 

1129 related_name='assigned_stock', 

1130 help_text=_('Customer'), 

1131 verbose_name=_('Customer'), 

1132 ) 

1133 

1134 serial = models.CharField( 

1135 verbose_name=_('Serial Number'), 

1136 max_length=100, 

1137 blank=True, 

1138 null=True, 

1139 help_text=_('Serial number for this item'), 

1140 ) 

1141 

1142 serial_int = models.IntegerField(default=0) 

1143 

1144 link = InvenTreeURLField( 

1145 verbose_name=_('External Link'), 

1146 blank=True, 

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

1148 max_length=2000, 

1149 ) 

1150 

1151 batch = models.CharField( 

1152 verbose_name=_('Batch Code'), 

1153 max_length=100, 

1154 blank=True, 

1155 null=True, 

1156 help_text=_('Batch code for this stock item'), 

1157 default=generate_batch_code, 

1158 ) 

1159 

1160 quantity = models.DecimalField( 

1161 verbose_name=_('Stock Quantity'), 

1162 max_digits=15, 

1163 decimal_places=5, 

1164 validators=[MinValueValidator(0)], 

1165 default=1, 

1166 ) 

1167 

1168 build = models.ForeignKey( 

1169 'build.Build', 

1170 on_delete=models.SET_NULL, 

1171 verbose_name=_('Source Build'), 

1172 blank=True, 

1173 null=True, 

1174 help_text=_('Build for this stock item'), 

1175 related_name='build_outputs', 

1176 ) 

1177 

1178 consumed_by = models.ForeignKey( 

1179 'build.Build', 

1180 on_delete=models.CASCADE, 

1181 verbose_name=_('Consumed By'), 

1182 blank=True, 

1183 null=True, 

1184 help_text=_('Build order which consumed this stock item'), 

1185 related_name='consumed_stock', 

1186 ) 

1187 

1188 is_building = models.BooleanField(default=False) 

1189 

1190 purchase_order = models.ForeignKey( 

1191 'order.PurchaseOrder', 

1192 on_delete=models.SET_NULL, 

1193 verbose_name=_('Source Purchase Order'), 

1194 related_name='stock_items', 

1195 blank=True, 

1196 null=True, 

1197 help_text=_('Purchase order for this stock item'), 

1198 ) 

1199 

1200 sales_order = models.ForeignKey( 

1201 'order.SalesOrder', 

1202 on_delete=models.SET_NULL, 

1203 verbose_name=_('Destination Sales Order'), 

1204 related_name='stock_items', 

1205 null=True, 

1206 blank=True, 

1207 ) 

1208 

1209 expiry_date = models.DateField( 

1210 blank=True, 

1211 null=True, 

1212 verbose_name=_('Expiry Date'), 

1213 help_text=_( 

1214 'Expiry date for stock item. Stock will be considered expired after this date' 

1215 ), 

1216 ) 

1217 

1218 stocktake_date = models.DateField(blank=True, null=True) 

1219 

1220 stocktake_user = models.ForeignKey( 

1221 User, 

1222 on_delete=models.SET_NULL, 

1223 blank=True, 

1224 null=True, 

1225 related_name='stocktake_stock', 

1226 ) 

1227 

1228 creation_date = models.DateTimeField( 

1229 null=True, 

1230 blank=True, 

1231 auto_now_add=True, 

1232 editable=False, 

1233 verbose_name=_('Creation Date'), 

1234 help_text=_('Date that this stock item was created'), 

1235 ) 

1236 

1237 delete_on_deplete = models.BooleanField( 

1238 default=default_delete_on_deplete, 

1239 verbose_name=_('Delete on deplete'), 

1240 help_text=_('Delete this Stock Item when stock is depleted'), 

1241 ) 

1242 

1243 status = InvenTreeCustomStatusModelField( 

1244 default=StockStatus.OK.value, 

1245 status_class=StockStatus, 

1246 choices=StockStatus.items(), 

1247 validators=[MinValueValidator(0)], 

1248 ) 

1249 

1250 @property 

1251 def status_text(self): 

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

1253 return StockStatus.text(self.status) 

1254 

1255 purchase_price = InvenTreeModelMoneyField( 

1256 max_digits=19, 

1257 decimal_places=6, 

1258 blank=True, 

1259 null=True, 

1260 verbose_name=_('Purchase Price'), 

1261 help_text=_('Single unit purchase price at time of purchase'), 

1262 ) 

1263 

1264 owner = models.ForeignKey( 

1265 Owner, 

1266 on_delete=models.SET_NULL, 

1267 blank=True, 

1268 null=True, 

1269 verbose_name=_('Owner'), 

1270 help_text=_('Select Owner'), 

1271 related_name='stock_items', 

1272 ) 

1273 

1274 @transaction.atomic 

1275 def convert_to_variant(self, variant, user, notes=None): 

1276 """Convert this StockItem instance to a "variant", i.e. change the "part" reference field.""" 

1277 if not variant: 

1278 # Ignore null values 

1279 return 

1280 

1281 if variant == self.part: 

1282 # Variant is the same as the current part 

1283 return 

1284 

1285 self.part = variant 

1286 self.save() 

1287 

1288 self.add_tracking_entry( 

1289 StockHistoryCode.CONVERTED_TO_VARIANT, 

1290 user, 

1291 deltas={'part': variant.pk}, 

1292 notes=_('Converted to part') + ': ' + variant.full_name, 

1293 ) 

1294 

1295 def get_item_owner(self): 

1296 """Return the closest "owner" for this StockItem. 

1297 

1298 - If the item has an owner set, return that 

1299 - If the item is "in stock", check the StockLocation 

1300 - Otherwise, return None 

1301 """ 

1302 if self.owner is not None: 

1303 return self.owner 

1304 

1305 if self.in_stock and self.location is not None: 

1306 loc_owner = self.location.get_location_owner() 

1307 

1308 if loc_owner: 

1309 return loc_owner 

1310 

1311 return None 

1312 

1313 def check_ownership(self, user): 

1314 """Check if the user "owns" (or is one of the owners of) the item.""" 

1315 # Superuser accounts automatically "own" everything 

1316 if user.is_superuser: 

1317 return True 

1318 

1319 ownership_enabled = get_global_setting('STOCK_OWNERSHIP_CONTROL') 

1320 

1321 if not ownership_enabled: 

1322 # Location ownership function is not enabled, so return True 

1323 return True 

1324 

1325 owner = self.get_item_owner() 

1326 

1327 if owner is None: 

1328 return True 

1329 

1330 return owner.is_user_allowed(user, include_group=True) 

1331 

1332 def is_stale(self): 

1333 """Returns True if this Stock item is "stale". 

1334 

1335 To be "stale", the following conditions must be met: 

1336 - Expiry date is not None 

1337 - Expiry date will "expire" within the configured stale date 

1338 - The StockItem is otherwise "in stock" 

1339 """ 

1340 if self.expiry_date is None: 

1341 return False 

1342 

1343 if not self.in_stock: 

1344 return False 

1345 

1346 today = InvenTree.helpers.current_date() 

1347 

1348 stale_days = get_global_setting('STOCK_STALE_DAYS') 

1349 

1350 if stale_days <= 0: 

1351 return False 

1352 

1353 expiry_date = today + timedelta(days=stale_days) 

1354 

1355 return self.expiry_date < expiry_date 

1356 

1357 def is_expired(self): 

1358 """Returns True if this StockItem is "expired". 

1359 

1360 To be "expired", the following conditions must be met: 

1361 - Expiry date is not None 

1362 - Expiry date is "in the past" 

1363 - The StockItem is otherwise "in stock" 

1364 """ 

1365 if self.expiry_date is None: 

1366 return False 

1367 

1368 if not self.in_stock: 

1369 return False 

1370 

1371 today = InvenTree.helpers.current_date() 

1372 

1373 return self.expiry_date < today 

1374 

1375 def clearAllocations(self): 

1376 """Clear all order allocations for this StockItem. 

1377 

1378 Clears: 

1379 - SalesOrder allocations 

1380 - Build allocations 

1381 """ 

1382 # Delete outstanding SalesOrder allocations 

1383 self.sales_order_allocations.all().delete() 

1384 

1385 # Delete outstanding BuildOrder allocations 

1386 self.allocations.all().delete() 

1387 

1388 def allocateToCustomer( 

1389 self, customer, quantity=None, order=None, user=None, notes=None 

1390 ): 

1391 """Allocate a StockItem to a customer. 

1392 

1393 This action can be called by the following processes: 

1394 - Completion of a SalesOrder 

1395 - User manually assigns a StockItem to the customer 

1396 

1397 Args: 

1398 customer: The customer (Company) to assign the stock to 

1399 quantity: Quantity to assign (if not supplied, total quantity is used) 

1400 order: SalesOrder reference 

1401 user: User that performed the action 

1402 notes: Notes field 

1403 """ 

1404 if quantity is None: 

1405 quantity = self.quantity 

1406 

1407 if quantity >= self.quantity: 

1408 item = self 

1409 else: 

1410 item = self.splitStock(quantity, None, user) 

1411 

1412 # Update StockItem fields with new information 

1413 item.sales_order = order 

1414 item.customer = customer 

1415 item.location = None 

1416 

1417 item.save(add_note=False) 

1418 

1419 code = StockHistoryCode.SENT_TO_CUSTOMER 

1420 deltas = {'quantity': float(quantity)} 

1421 

1422 if customer is not None: 

1423 deltas['customer'] = customer.pk 

1424 deltas['customer_name'] = customer.name 

1425 

1426 # If an order is provided, we are shipping against a SalesOrder, not manually! 

1427 if order: 

1428 code = StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER 

1429 deltas['salesorder'] = order.pk 

1430 

1431 item.add_tracking_entry(code, user, deltas, notes=notes) 

1432 

1433 trigger_event( 

1434 StockEvents.ITEM_ASSIGNED_TO_CUSTOMER, 

1435 id=self.id, 

1436 customer=customer.id if customer else None, 

1437 ) 

1438 

1439 # Return the reference to the stock item 

1440 return item 

1441 

1442 @transaction.atomic 

1443 def return_from_customer(self, location, user=None, **kwargs): 

1444 """Return stock item from customer, back into the specified location. 

1445 

1446 Arguments: 

1447 location: The location to return the stock item to 

1448 user: The user performing the action 

1449 

1450 Keyword Arguments: 

1451 notes: Additional notes to add to the tracking entry 

1452 status: Optionally set the status of the stock item 

1453 

1454 If the selected location is the same as the parent, merge stock back into the parent. 

1455 Otherwise create the stock in the new location. 

1456 

1457 Note that this function is provided for legacy compatibility, 

1458 and the 'return_to_stock' function should be used instead. 

1459 """ 

1460 self.return_to_stock( 

1461 location, 

1462 user, 

1463 tracking_code=StockHistoryCode.RETURNED_FROM_CUSTOMER, 

1464 **kwargs, 

1465 ) 

1466 

1467 @transaction.atomic 

1468 def return_to_stock( 

1469 self, location, user=None, quantity=None, merge: bool = True, **kwargs 

1470 ): 

1471 """Return stock item into stock, removing any consumption status. 

1472 

1473 Arguments: 

1474 location: The location to return the stock item to 

1475 user: The user performing the action 

1476 quantity: If specified, the quantity to return to stock (default is the full quantity) 

1477 merge: If True, attempt to merge this stock item back into the parent stock item 

1478 """ 

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

1480 

1481 tracking_code = kwargs.get('tracking_code', StockHistoryCode.RETURNED_TO_STOCK) 

1482 

1483 item = self 

1484 

1485 if quantity is not None and not self.serialized: 

1486 # If quantity is specified, we are splitting the stock item 

1487 if quantity <= 0: 

1488 raise ValidationError({ 

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

1490 }) 

1491 

1492 if quantity > self.quantity: 

1493 raise ValidationError({ 

1494 'quantity': _('Quantity exceeds available stock') 

1495 }) 

1496 

1497 if quantity < self.quantity: 

1498 # Split the stock item 

1499 item = self.splitStock(quantity, None, user) 

1500 

1501 tracking_info = { 

1502 'quantity': float(quantity) 

1503 if quantity is not None 

1504 else float(item.quantity) 

1505 } 

1506 

1507 if location: 

1508 tracking_info['location'] = location.pk 

1509 

1510 if item.customer: 

1511 tracking_info['customer'] = item.customer.id 

1512 tracking_info['customer_name'] = item.customer.name 

1513 

1514 if item.consumed_by: 

1515 tracking_info['build_order'] = item.consumed_by.id 

1516 

1517 # Clear out allocation information for the stock item 

1518 item.consumed_by = None 

1519 item.customer = None 

1520 item.belongs_to = None 

1521 item.sales_order = None 

1522 item.location = location 

1523 

1524 if status := kwargs.pop('status', None): 

1525 if not item.compare_status(status): 

1526 old_custom_status = item.get_custom_status() 

1527 old_status_logical = item.status 

1528 item.set_status(status) 

1529 tracking_info['status'] = status # may be a custom value 

1530 tracking_info['status_logical'] = ( 

1531 item.status 

1532 ) # always the logical value 

1533 tracking_info['old_status'] = ( 

1534 old_custom_status if old_custom_status else old_status_logical 

1535 ) 

1536 tracking_info['old_status_logical'] = old_status_logical 

1537 

1538 item.save() 

1539 

1540 item.clearAllocations() 

1541 

1542 item.add_tracking_entry( 

1543 tracking_code, user, notes=notes, deltas=tracking_info, location=location 

1544 ) 

1545 

1546 trigger_event(StockEvents.ITEM_RETURNED_TO_STOCK, id=item.id) 

1547 

1548 # Attempt to merge returned item into parent item: 

1549 # - 'merge' parameter is True 

1550 # - The parent location is the same as the current location 

1551 # - The item does not have a serial number 

1552 

1553 if ( 

1554 merge 

1555 and not item.serialized 

1556 and self.parent 

1557 and item.location == self.parent.location 

1558 ): 

1559 self.parent.merge_stock_items( 

1560 {item}, user=user, location=location, notes=notes 

1561 ) 

1562 else: 

1563 item.save(add_note=False) 

1564 

1565 def is_allocated(self): 

1566 """Return True if this StockItem is allocated to a SalesOrder, TransferOrder, or a Build.""" 

1567 return self.allocation_count() > 0 

1568 

1569 def build_allocation_count(self, **kwargs): 

1570 """Return the total quantity allocated to builds, with optional filters.""" 

1571 query = self.allocations.all() 

1572 

1573 if filter_allocations := kwargs.get('filter_allocations'): 

1574 query = query.filter(**filter_allocations) 

1575 

1576 if exclude_allocations := kwargs.get('exclude_allocations'): 

1577 query = query.exclude(**exclude_allocations) 

1578 

1579 query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) 

1580 

1581 total = query['q'] 

1582 

1583 if total is None: 

1584 total = Decimal(0) 

1585 

1586 return total 

1587 

1588 def get_sales_order_allocations(self, active=True, **kwargs): 

1589 """Return a queryset for SalesOrderAllocations against this StockItem, with optional filters. 

1590 

1591 Arguments: 

1592 active: Filter by 'active' status of the allocation 

1593 """ 

1594 query = self.sales_order_allocations.all() 

1595 

1596 if filter_allocations := kwargs.get('filter_allocations'): 

1597 query = query.filter(**filter_allocations) 

1598 

1599 if exclude_allocations := kwargs.get('exclude_allocations'): 

1600 query = query.exclude(**exclude_allocations) 

1601 

1602 if active is True: 

1603 query = query.filter( 

1604 line__order__status__in=SalesOrderStatusGroups.OPEN, 

1605 shipment__shipment_date=None, 

1606 ) 

1607 elif active is False: 

1608 query = query.exclude( 

1609 line__order__status__in=SalesOrderStatusGroups.OPEN 

1610 ).exclude(shipment__shipment_date=None) 

1611 

1612 return query 

1613 

1614 def sales_order_allocation_count(self, active=True, **kwargs): 

1615 """Return the total quantity allocated to SalesOrders.""" 

1616 query = self.get_sales_order_allocations(active=active, **kwargs) 

1617 query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) 

1618 

1619 total = query['q'] 

1620 

1621 if total is None: 

1622 total = Decimal(0) 

1623 

1624 return total 

1625 

1626 def get_transfer_order_allocations(self, active=True, **kwargs): 

1627 """Return a queryset for TransferOrderAllocations against this StockItem, with optional filters. 

1628 

1629 Arguments: 

1630 active: Filter by 'active' status of the allocation 

1631 """ 

1632 query = self.transfer_order_allocations.all() 

1633 

1634 if filter_allocations := kwargs.get('filter_allocations'): 

1635 query = query.filter(**filter_allocations) 

1636 

1637 if exclude_allocations := kwargs.get('exclude_allocations'): 

1638 query = query.exclude(**exclude_allocations) 

1639 

1640 if active is True: 

1641 query = query.filter(line__order__status__in=TransferOrderStatusGroups.OPEN) 

1642 elif active is False: 

1643 query = query.exclude( 

1644 line__order__status__in=TransferOrderStatusGroups.OPEN 

1645 ) 

1646 

1647 return query 

1648 

1649 def transfer_order_allocation_count(self, active=True, **kwargs): 

1650 """Return the total quantity allocated to TransferOrders.""" 

1651 query = self.get_transfer_order_allocations(active=active, **kwargs) 

1652 query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) 

1653 

1654 total = query['q'] 

1655 

1656 if total is None: 

1657 total = Decimal(0) 

1658 

1659 return total 

1660 

1661 def allocation_count(self): 

1662 """Return the total quantity allocated to builds or orders.""" 

1663 bo = self.build_allocation_count() 

1664 so = self.sales_order_allocation_count() 

1665 to = self.transfer_order_allocation_count() 

1666 

1667 return bo + so + to 

1668 

1669 def unallocated_quantity(self): 

1670 """Return the quantity of this StockItem which is *not* allocated.""" 

1671 return max(self.quantity - self.allocation_count(), 0) 

1672 

1673 def can_delete(self): 

1674 """Can this stock item be deleted? 

1675 

1676 It can NOT be deleted under the following circumstances: 

1677 - Has installed stock items 

1678 - Is installed inside another StockItem 

1679 - It has been assigned to a SalesOrder 

1680 - It has been assigned to a BuildOrder 

1681 """ 

1682 if self.installed_item_count() > 0: 

1683 return False 

1684 

1685 return self.sales_order is None 

1686 

1687 def get_installed_items(self, cascade: bool = False) -> set[StockItem]: 

1688 """Return all stock items which are *installed* in this one! 

1689 

1690 Note: This function is recursive, and may result in a number of database hits! 

1691 

1692 Args: 

1693 cascade (bool, optional): Include items which are installed in items which are installed in items. Defaults to False. 

1694 

1695 Returns: 

1696 set[StockItem]: Sll stock items which are installed 

1697 """ 

1698 installed = set() 

1699 

1700 items = StockItem.objects.filter(belongs_to=self) 

1701 

1702 for item in items: 

1703 # Prevent duplication or recursion 

1704 if item == self or item in installed: 

1705 continue 

1706 

1707 installed.add(item) 

1708 

1709 if cascade: 

1710 sub_items = item.get_installed_items(cascade=True) 

1711 

1712 for sub_item in sub_items: 

1713 # Prevent recursion 

1714 if sub_item == self or sub_item in installed: 

1715 continue 

1716 

1717 installed.add(sub_item) 

1718 

1719 return installed 

1720 

1721 def installed_item_count(self): 

1722 """Return the number of stock items installed inside this one.""" 

1723 return self.installed_parts.count() 

1724 

1725 @transaction.atomic 

1726 def installStockItem(self, other_item, quantity, user, notes, build=None): 

1727 """Install another stock item into this stock item. 

1728 

1729 Args: 

1730 other_item: The stock item to install into this stock item 

1731 quantity: The quantity of stock to install 

1732 user: The user performing the operation 

1733 notes: Any notes associated with the operation 

1734 build: The BuildOrder to associate with the operation (optional) 

1735 """ 

1736 # If the quantity is less than the stock item, split the stock! 

1737 stock_item = other_item.splitStock(quantity, None, user) 

1738 

1739 if stock_item is None: 

1740 stock_item = other_item 

1741 

1742 # Assign the other stock item into this one 

1743 stock_item.belongs_to = self 

1744 

1745 if build is not None: 

1746 stock_item.consumed_by = build 

1747 

1748 stock_item.location = None 

1749 stock_item.save(add_note=False) 

1750 

1751 deltas = {'stockitem': self.pk, 'quantity': float(quantity)} 

1752 

1753 if build is not None: 

1754 deltas['buildorder'] = build.pk 

1755 

1756 # Add a transaction note to the other item 

1757 stock_item.add_tracking_entry( 

1758 StockHistoryCode.INSTALLED_INTO_ASSEMBLY, user, notes=notes, deltas=deltas 

1759 ) 

1760 

1761 # Add a transaction note to this item (the assembly) 

1762 self.add_tracking_entry( 

1763 StockHistoryCode.INSTALLED_CHILD_ITEM, 

1764 user, 

1765 notes=notes, 

1766 deltas={'stockitem': stock_item.pk, 'quantity': float(quantity)}, 

1767 ) 

1768 

1769 trigger_event( 

1770 StockEvents.ITEM_INSTALLED_INTO_ASSEMBLY, 

1771 id=stock_item.pk, 

1772 assembly_id=self.pk, 

1773 ) 

1774 

1775 @transaction.atomic 

1776 def uninstall_into_location(self, location, user, notes): 

1777 """Uninstall this stock item from another item, into a location. 

1778 

1779 Args: 

1780 location: The stock location where the item will be moved 

1781 user: The user performing the operation 

1782 notes: Any notes associated with the operation 

1783 """ 

1784 # If the stock item is not installed in anything, ignore 

1785 if self.belongs_to is None: 

1786 return False 

1787 

1788 # Add a transaction note to the parent item 

1789 self.belongs_to.add_tracking_entry( 

1790 StockHistoryCode.REMOVED_CHILD_ITEM, 

1791 user, 

1792 deltas={'stockitem': self.pk, 'quantity': float(self.quantity)}, 

1793 notes=notes, 

1794 ) 

1795 

1796 tracking_info = { 

1797 'stockitem': self.belongs_to.pk, 

1798 'quantity': float(self.quantity), 

1799 } 

1800 

1801 self.add_tracking_entry( 

1802 StockHistoryCode.REMOVED_FROM_ASSEMBLY, 

1803 user, 

1804 notes=notes, 

1805 deltas=tracking_info, 

1806 location=location, 

1807 ) 

1808 

1809 # Mark this stock item as *not* belonging to anyone 

1810 self.belongs_to = None 

1811 self.consumed_by = None 

1812 self.location = location 

1813 

1814 self.save() 

1815 

1816 @property 

1817 def children(self): 

1818 """Return a list of the child items which have been split from this stock item.""" 

1819 return self.get_descendants(include_self=False) 

1820 

1821 @property 

1822 def child_count(self): 

1823 """Return the number of 'child' items associated with this StockItem. 

1824 

1825 A child item is one which has been split from this one. 

1826 """ 

1827 return self.children.count() 

1828 

1829 def is_in_stock( 

1830 self, 

1831 check_status: bool = True, 

1832 check_quantity: bool = True, 

1833 check_in_production: bool = True, 

1834 ) -> bool: 

1835 """Return True if this StockItem is "in stock". 

1836 

1837 Arguments: 

1838 check_status: If True, check the status of the StockItem. Defaults to True. 

1839 check_quantity: If True, check the quantity of the StockItem. Defaults to True. 

1840 check_in_production: If True, check if the item is in production. Defaults to True. 

1841 """ 

1842 if check_status and self.status not in StockStatusGroups.AVAILABLE_CODES: 

1843 return False 

1844 

1845 if check_quantity and self.quantity <= 0: 

1846 return False 

1847 

1848 if check_in_production and self.is_building: 

1849 return False 

1850 

1851 return all([ 

1852 self.sales_order is None, # Not assigned to a SalesOrder 

1853 self.belongs_to is None, # Not installed inside another StockItem 

1854 self.customer is None, # Not assigned to a customer 

1855 self.consumed_by is None, # Not consumed by a build 

1856 ]) 

1857 

1858 @property 

1859 def in_stock(self) -> bool: 

1860 """Returns True if this item is in stock. 

1861 

1862 See also: StockItem.IN_STOCK_FILTER for the db optimized version of this check. 

1863 """ 

1864 return self.is_in_stock(check_status=True) 

1865 

1866 @property 

1867 def can_adjust_location(self): 

1868 """Returns True if the stock location can be "adjusted" for this part. 

1869 

1870 Cannot be adjusted if: 

1871 - Has been delivered to a customer 

1872 - Has been installed inside another StockItem 

1873 """ 

1874 if self.customer is not None: 

1875 return False 

1876 

1877 if self.belongs_to is not None: 

1878 return False 

1879 

1880 return self.sales_order is None 

1881 

1882 @property 

1883 def tracking_info_count(self): 

1884 """How many tracking entries are available?""" 

1885 return self.tracking_info.count() 

1886 

1887 @property 

1888 def has_tracking_info(self): 

1889 """Is tracking info available?""" 

1890 return self.tracking_info_count > 0 

1891 

1892 def add_tracking_entry( 

1893 self, 

1894 entry_type: int, 

1895 user: User, 

1896 deltas: dict | None = None, 

1897 notes: str = '', 

1898 commit: bool = True, 

1899 **kwargs, 

1900 ): 

1901 """Add a history tracking entry for this StockItem. 

1902 

1903 Args: 

1904 entry_type (int): Code describing the "type" of historical action (see StockHistoryCode) 

1905 user (User): The user performing this action 

1906 deltas (dict, optional): A map of the changes made to the model. Defaults to None. 

1907 notes (str, optional): URL associated with this tracking entry. Defaults to ''. 

1908 commit (bool, optional): If True, save the entry to the database. Defaults to True. 

1909 

1910 Returns: 

1911 StockItemTracking: The created tracking entry 

1912 """ 

1913 if deltas is None: 

1914 deltas = {} 

1915 

1916 # Prevent empty entry 

1917 if ( 

1918 entry_type == StockHistoryCode.STOCK_UPDATE 

1919 and len(deltas) == 0 

1920 and not notes 

1921 ): 

1922 return None 

1923 

1924 # Has a location been specified? 

1925 location = kwargs.get('location') 

1926 

1927 if location: 

1928 deltas['location'] = location.id 

1929 

1930 # Quantity specified? 

1931 quantity = kwargs.get('quantity') 

1932 

1933 if quantity: 

1934 deltas['quantity'] = float(quantity) 

1935 

1936 entry = StockItemTracking( 

1937 item=self, 

1938 part=self.part, 

1939 tracking_type=entry_type.value, 

1940 user=user, 

1941 date=InvenTree.helpers.current_time(), 

1942 notes=notes, 

1943 deltas=deltas, 

1944 ) 

1945 

1946 if commit: 

1947 entry.save() 

1948 

1949 return entry 

1950 

1951 @transaction.atomic 

1952 def serializeStock( 

1953 self, 

1954 quantity: int, 

1955 serials: list[str], 

1956 user: User | None = None, 

1957 notes: str | None = '', 

1958 location: StockLocation | None = None, 

1959 ): 

1960 """Split this stock item into unique serial numbers. 

1961 

1962 - Quantity can be less than or equal to the quantity of the stock item 

1963 - Number of serial numbers must match the quantity 

1964 - Provided serial numbers must not already be in use 

1965 

1966 Arguments: 

1967 quantity: Number of items to serialize (integer) 

1968 serials: List of serial numbers 

1969 user: User object associated with action 

1970 notes: Optional notes for tracking 

1971 location: If specified, serialized items will be placed in the given location 

1972 

1973 Returns: 

1974 List of newly created StockItem objects, each with a unique serial number. 

1975 """ 

1976 # Cannot serialize stock that is already serialized! 

1977 if self.serialized: 

1978 return None 

1979 

1980 if not self.part.trackable: 

1981 raise ValidationError({'part': _('Part is not set as trackable')}) 

1982 

1983 # Quantity must be a valid integer value 

1984 try: 

1985 quantity = int(quantity) 

1986 except ValueError: 

1987 raise ValidationError({'quantity': _('Quantity must be integer')}) 

1988 

1989 if quantity <= 0: 

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

1991 

1992 if quantity > self.quantity: 

1993 raise ValidationError({ 

1994 'quantity': _( 

1995 f'Quantity must not exceed available stock quantity ({self.quantity})' 

1996 ) 

1997 }) 

1998 

1999 if type(serials) not in [list, tuple]: 

2000 raise ValidationError({ 

2001 'serial_numbers': _('Serial numbers must be provided as a list') 

2002 }) 

2003 

2004 if quantity != len(serials): 

2005 raise ValidationError({ 

2006 'quantity': _('Quantity does not match serial numbers') 

2007 }) 

2008 

2009 # Test if each of the serial numbers are valid 

2010 existing = self.part.find_conflicting_serial_numbers(serials) 

2011 

2012 if len(existing) > 0: 

2013 msg = _('The following serial numbers already exist or are invalid') 

2014 msg += ' : ' 

2015 msg += ','.join([str(x) for x in existing]) 

2016 raise ValidationError({'serial_numbers': msg}) 

2017 

2018 # Serialize this StockItem 

2019 data = dict(StockItem.objects.filter(pk=self.pk).values()[0]) 

2020 

2021 if location: 

2022 if location.structural: 

2023 raise ValidationError({ 

2024 'location': _('Cannot assign stock to structural location') 

2025 }) 

2026 

2027 data['location_id'] = location.pk 

2028 

2029 # Set the parent ID correctly 

2030 data['parent'] = self 

2031 data['tree_id'] = self.tree_id 

2032 

2033 # Generate a new serial number for each item 

2034 items = StockItem._create_serial_numbers(serials, **data) 

2035 

2036 # Create a new tracking entry for each item 

2037 history_items = [] 

2038 

2039 for item in items: 

2040 # Construct tracking entries for the new StockItem 

2041 if entry := item.add_tracking_entry( 

2042 StockHistoryCode.SPLIT_FROM_PARENT, 

2043 user, 

2044 quantity=1, 

2045 notes=notes, 

2046 location=location, 

2047 commit=False, 

2048 ): 

2049 history_items.append(entry) 

2050 

2051 if entry := item.add_tracking_entry( 

2052 StockHistoryCode.ASSIGNED_SERIAL, 

2053 user, 

2054 notes=notes, 

2055 deltas={'serial': item.serial}, 

2056 location=location, 

2057 commit=False, 

2058 ): 

2059 history_items.append(entry) 

2060 

2061 # Copy any test results from this item to the new one 

2062 item.copyTestResultsFrom(self) 

2063 

2064 StockItemTracking.objects.bulk_create(history_items, batch_size=250) 

2065 

2066 # Remove the equivalent number of items 

2067 self.take_stock( 

2068 quantity, user, code=StockHistoryCode.STOCK_SERIALIZED, notes=notes 

2069 ) 

2070 

2071 return items 

2072 

2073 @transaction.atomic 

2074 def copyHistoryFrom(self, other): 

2075 """Copy stock history from another StockItem.""" 

2076 for item in other.tracking_info.all(): 

2077 item.item = self 

2078 item.pk = None 

2079 item.save() 

2080 

2081 @transaction.atomic 

2082 def copyTestResultsFrom(self, other: StockItem, filters: dict | None = None): 

2083 """Copy all test results from another StockItem.""" 

2084 # Set default - see B006 

2085 

2086 results = other.test_results.all() 

2087 

2088 if filters: 

2089 results = results.filter(**filters) 

2090 

2091 results_to_create = [] 

2092 

2093 for result in list(results): 

2094 # Create a copy of the test result by nulling-out the pk 

2095 result.pk = None 

2096 result.stock_item = self 

2097 results_to_create.append(result) 

2098 

2099 StockItemTestResult.objects.bulk_create(results_to_create, batch_size=250) 

2100 

2101 def add_test_result(self, create_template=True, **kwargs): 

2102 """Helper function to add a new StockItemTestResult. 

2103 

2104 The main purpose of this function is to allow lookup of the template, 

2105 based on the provided test name. 

2106 

2107 If no template is found, a new one is created (if create_template=True). 

2108 

2109 Args: 

2110 create_template: If True, create a new template if it does not exist 

2111 

2112 kwargs: 

2113 template: The ID of the associated PartTestTemplate 

2114 test_name: The name of the test (if the template is not provided) 

2115 result: The result of the test 

2116 value: The value of the test 

2117 user: The user who performed the test 

2118 notes: Any notes associated with the test 

2119 """ 

2120 template = kwargs.get('template') 

2121 test_name = kwargs.pop('test_name', None) 

2122 

2123 test_key = InvenTree.helpers.generateTestKey(test_name) 

2124 

2125 if template is None and test_name is not None: 

2126 # Attempt to find a matching template 

2127 

2128 ancestors = self.part.get_ancestors(include_self=True) 

2129 

2130 template = PartModels.PartTestTemplate.objects.filter( 

2131 part__tree_id=self.part.tree_id, part__in=ancestors, key=test_key 

2132 ).first() 

2133 

2134 if template is None: 

2135 if create_template: 

2136 template = PartModels.PartTestTemplate.objects.create( 

2137 part=self.part, test_name=test_name 

2138 ) 

2139 else: 

2140 raise ValidationError({ 

2141 'template': _('Test template does not exist') 

2142 }) 

2143 

2144 kwargs['template'] = template 

2145 kwargs['stock_item'] = self 

2146 

2147 return StockItemTestResult.objects.create(**kwargs) 

2148 

2149 def can_merge(self, other=None, raise_error=False, **kwargs): 

2150 """Check if this stock item can be merged into another stock item.""" 

2151 allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False) 

2152 

2153 allow_mismatched_status = kwargs.get('allow_mismatched_status', False) 

2154 

2155 try: 

2156 # Generic checks (do not rely on the 'other' part) 

2157 if self.sales_order: 

2158 raise ValidationError( 

2159 _('Stock item has been assigned to a sales order') 

2160 ) 

2161 

2162 if self.belongs_to: 

2163 raise ValidationError(_('Stock item is installed in another item')) 

2164 

2165 if self.installed_item_count() > 0: 

2166 raise ValidationError(_('Stock item contains other items')) 

2167 

2168 if self.customer: 

2169 raise ValidationError(_('Stock item has been assigned to a customer')) 

2170 

2171 if self.is_building: 

2172 raise ValidationError(_('Stock item is currently in production')) 

2173 

2174 if self.serialized: 

2175 raise ValidationError(_('Serialized stock cannot be merged')) 

2176 

2177 if other: 

2178 # Specific checks (rely on the 'other' part) 

2179 

2180 # Prevent stock item being merged with itself 

2181 if self == other: 

2182 raise ValidationError(_('Duplicate stock items')) 

2183 

2184 # Base part must match 

2185 if self.part != other.part: 

2186 raise ValidationError(_('Stock items must refer to the same part')) 

2187 

2188 # Check if supplier part references match 

2189 if ( 

2190 self.supplier_part != other.supplier_part 

2191 and not allow_mismatched_suppliers 

2192 ): 

2193 raise ValidationError( 

2194 _('Stock items must refer to the same supplier part') 

2195 ) 

2196 

2197 # Check if stock status codes match 

2198 if self.status != other.status and not allow_mismatched_status: 

2199 raise ValidationError(_('Stock status codes must match')) 

2200 

2201 except ValidationError as e: 

2202 if raise_error: 

2203 raise e 

2204 else: 

2205 return False 

2206 

2207 return True 

2208 

2209 @transaction.atomic 

2210 def merge_stock_items(self, other_items, raise_error=False, **kwargs): 

2211 """Merge another stock item into this one; the two become one! 

2212 

2213 *This* stock item subsumes the other, which is essentially deleted: 

2214 

2215 - The quantity of this StockItem is increased 

2216 - Tracking history for the *other* item is deleted 

2217 - Any allocations (build order, sales order) are moved to this StockItem 

2218 """ 

2219 if isinstance(other_items, StockItem): 

2220 other_items = [other_items] 

2221 

2222 if len(other_items) == 0: 

2223 return 

2224 

2225 # Keep track of the tree IDs that are being merged 

2226 tree_ids = {self.tree_id} 

2227 

2228 user = kwargs.get('user') 

2229 location = kwargs.get('location', self.location) 

2230 notes = kwargs.get('notes') 

2231 

2232 parent_id = self.parent.pk if self.parent else None 

2233 

2234 # Keep track of pricing data for the merged data 

2235 pricing_data = [] 

2236 

2237 if self.purchase_price: 

2238 pricing_data.append([self.purchase_price, self.quantity]) 

2239 

2240 for other in other_items: 

2241 # If the stock item cannot be merged, return 

2242 if not self.can_merge(other, raise_error=raise_error, **kwargs): 

2243 logger.warning( 

2244 'Stock item <%s> could not be merge into <%s>', other.pk, self.pk 

2245 ) 

2246 return 

2247 

2248 for other in other_items: 

2249 tree_ids.add(other.tree_id) 

2250 

2251 self.quantity += other.quantity 

2252 

2253 if other.purchase_price: 

2254 # Only add pricing data if it is available 

2255 pricing_data.append([other.purchase_price, other.quantity]) 

2256 

2257 # Any "build order allocations" for the other item must be assigned to this one 

2258 for allocation in other.allocations.all(): 

2259 allocation.stock_item = self 

2260 allocation.save() 

2261 

2262 # Any "sales order allocations" for the other item must be assigned to this one 

2263 for allocation in other.sales_order_allocations.all(): 

2264 allocation.stock_item = self 

2265 allocation.save() 

2266 

2267 # Prevent atomicity issues when we are merging our own "parent" part in 

2268 if parent_id and parent_id == other.pk: 

2269 self.parent = None 

2270 self.save() 

2271 

2272 other.delete() 

2273 

2274 self.add_tracking_entry( 

2275 StockHistoryCode.MERGED_STOCK_ITEMS, 

2276 user, 

2277 quantity=self.quantity, 

2278 notes=notes, 

2279 deltas={ 

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

2281 'quantity': self.quantity, 

2282 }, 

2283 ) 

2284 

2285 # Update the location of the item 

2286 self.location = location 

2287 

2288 # Update the unit price - calculate weighted average of available pricing data 

2289 if len(pricing_data) > 0: 

2290 unit_price, quantity = pricing_data[0] 

2291 

2292 # Use the first currency as the base currency 

2293 base_currency = unit_price.currency 

2294 

2295 total_price = unit_price * quantity 

2296 

2297 for price, qty in pricing_data[1:]: 

2298 # Attempt to convert the price to the base currency 

2299 try: 

2300 price = convert_money(price, base_currency) 

2301 total_price += price * qty 

2302 quantity += qty 

2303 except Exception: 

2304 # Skip this entry, cannot convert to base currency 

2305 continue 

2306 

2307 if quantity > 0: 

2308 self.purchase_price = total_price / quantity 

2309 

2310 self.save() 

2311 

2312 # Rebuild stock trees as required 

2313 rebuild_result = True 

2314 for tree_id in tree_ids: 

2315 if not stock.tasks.rebuild_stock_item_tree(tree_id, rebuild_on_fail=False): 

2316 rebuild_result = False 

2317 

2318 if not rebuild_result: 

2319 # If the rebuild failed, offload the task to a background worker 

2320 logger.warning( 

2321 'Failed to rebuild stock item tree during merge_stock_items operation, offloading task.' 

2322 ) 

2323 InvenTree.tasks.offload_task(stock.tasks.rebuild_stock_items, group='stock') 

2324 

2325 @transaction.atomic 

2326 def splitStock(self, quantity, location=None, user=None, **kwargs): 

2327 """Split this stock item into two items, in the same location. 

2328 

2329 Stock tracking notes for this StockItem will be duplicated, 

2330 and added to the new StockItem. 

2331 

2332 Args: 

2333 quantity: Number of stock items to remove from this entity, and pass to the next 

2334 location: Where to move the new StockItem to 

2335 user: User performing the action 

2336 

2337 kwargs: 

2338 notes: Optional notes for tracking 

2339 batch: If provided, override the batch (default = existing batch) 

2340 status: If provided, override the status (default = existing status) 

2341 packaging: If provided, override the packaging (default = existing packaging) 

2342 allow_production: If True, allow splitting of stock which is in production (default = False) 

2343 

2344 Returns: 

2345 The new StockItem object 

2346 

2347 Raises: 

2348 ValidationError: If the stock item cannot be split 

2349 

2350 - The provided quantity will be subtracted from this item and given to the new one. 

2351 - The new item will have a different StockItem ID, while this will remain the same. 

2352 """ 

2353 # Run initial checks to test if the stock item can actually be "split" 

2354 allow_production = kwargs.get('allow_production', False) 

2355 

2356 # Cannot split a stock item which is in production 

2357 if self.is_building and not allow_production: 

2358 raise ValidationError(_('Stock item is currently in production')) 

2359 

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

2361 

2362 # Do not split a serialized part 

2363 if self.serialized: 

2364 return self 

2365 

2366 try: 

2367 quantity = Decimal(quantity) 

2368 except (InvalidOperation, ValueError): 

2369 return self 

2370 

2371 # Doesn't make sense for a zero quantity 

2372 if quantity <= 0: 

2373 return self 

2374 

2375 # Also doesn't make sense to split the full amount 

2376 if quantity >= self.quantity: 

2377 return self 

2378 

2379 # Create a new StockItem object, duplicating relevant fields 

2380 # Nullify the PK so a new record is created 

2381 new_stock = StockItem.objects.get(pk=self.pk) 

2382 new_stock.pk = None 

2383 new_stock.quantity = quantity 

2384 

2385 # Update the new stock item to ensure the tree structure is observed 

2386 new_stock.parent = self 

2387 new_stock.tree_id = None 

2388 

2389 # Move to the new location if specified, otherwise use current location 

2390 if location: 

2391 new_stock.location = location 

2392 else: 

2393 new_stock.location = self.location 

2394 

2395 deltas = {'stockitem': self.pk} 

2396 

2397 transferorder = kwargs.pop('transferorder', None) 

2398 if transferorder: 

2399 deltas['transferorder'] = transferorder.pk 

2400 

2401 # Optional fields which can be supplied in a 'move' call 

2402 for field in StockItem.optional_transfer_fields(): 

2403 if field in kwargs: 

2404 # handle specific case for status deltas 

2405 if field == 'status': 

2406 status = kwargs[field] 

2407 if not new_stock.compare_status(status): 

2408 old_custom_status = new_stock.get_custom_status() 

2409 old_status_logical = new_stock.status 

2410 new_stock.set_status(status) 

2411 deltas['status'] = status # may be a custom value 

2412 deltas['status_logical'] = ( 

2413 new_stock.status 

2414 ) # always the logical value 

2415 deltas['old_status'] = ( 

2416 old_custom_status 

2417 if old_custom_status 

2418 else old_status_logical 

2419 ) 

2420 deltas['old_status_logical'] = old_status_logical 

2421 else: 

2422 setattr(new_stock, field, kwargs[field]) 

2423 deltas[field] = kwargs[field] 

2424 

2425 new_stock.save(add_note=False) 

2426 

2427 # Add a stock tracking entry for the newly created item 

2428 new_stock.add_tracking_entry( 

2429 StockHistoryCode.SPLIT_FROM_PARENT, 

2430 user, 

2431 quantity=quantity, 

2432 notes=notes, 

2433 location=location, 

2434 deltas=deltas, 

2435 ) 

2436 

2437 # Copy the test results of this part to the new one 

2438 new_stock.copyTestResultsFrom(self) 

2439 

2440 # Remove the specified quantity from THIS stock item 

2441 self.take_stock( 

2442 quantity, 

2443 user, 

2444 code=StockHistoryCode.SPLIT_CHILD_ITEM, 

2445 notes=notes, 

2446 location=location, 

2447 stockitem=new_stock, 

2448 ) 

2449 

2450 # Rebuild the tree for this parent item 

2451 stock.tasks.rebuild_stock_item_tree(self.tree_id) 

2452 

2453 # Attempt to reload the new item from the database 

2454 try: 

2455 new_stock.refresh_from_db() 

2456 except Exception: 

2457 pass 

2458 

2459 trigger_event(StockEvents.ITEM_SPLIT, id=new_stock.id, parent=self.id) 

2460 

2461 # Return a copy of the "new" stock item 

2462 return new_stock 

2463 

2464 @classmethod 

2465 def optional_transfer_fields(cls): 

2466 """Returns a list of optional fields for a stock transfer.""" 

2467 return ['batch', 'status', 'packaging'] 

2468 

2469 @transaction.atomic 

2470 def move(self, location, notes, user, **kwargs): 

2471 """Move part to a new location. 

2472 

2473 If less than the available quantity is to be moved, 

2474 a new StockItem is created, with the defined quantity, 

2475 and that new StockItem is moved. 

2476 The quantity is also subtracted from the existing StockItem. 

2477 

2478 Args: 

2479 location: Destination location (cannot be null) 

2480 notes: User notes 

2481 user: Who is performing the move 

2482 

2483 kwargs: 

2484 quantity: If provided, override the quantity (default = total stock quantity) 

2485 batch: If provided, override the batch (default = existing batch) 

2486 status: If provided, override the status (default = existing status) 

2487 packaging: If provided, override the packaging (default = existing packaging) 

2488 """ 

2489 current_location = self.location 

2490 

2491 try: 

2492 quantity = Decimal(kwargs.pop('quantity', self.quantity)) 

2493 except InvalidOperation: 

2494 return False 

2495 

2496 allow_out_of_stock_transfer = get_global_setting( 

2497 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False 

2498 ) 

2499 

2500 if not allow_out_of_stock_transfer and not self.is_in_stock( 

2501 check_status=False, check_in_production=False 

2502 ): 

2503 raise ValidationError(_('StockItem cannot be moved as it is not in stock')) 

2504 

2505 if quantity <= 0: 

2506 return False 

2507 

2508 if location is None: 

2509 return False 

2510 

2511 # Test for a partial movement 

2512 if quantity < self.quantity: 

2513 # We need to split the stock! 

2514 

2515 kwargs['notes'] = notes 

2516 

2517 # Split the existing StockItem in two 

2518 self.splitStock(quantity, location, user, allow_production=True, **kwargs) 

2519 

2520 return True 

2521 

2522 # Moving into the same location triggers a different history code 

2523 same_location = location == self.location 

2524 

2525 self.location = location 

2526 

2527 tracking_info = {'quantity': float(quantity)} 

2528 

2529 tracking_code = StockHistoryCode.STOCK_MOVE 

2530 

2531 if same_location: 

2532 tracking_code = StockHistoryCode.STOCK_UPDATE 

2533 else: 

2534 tracking_info['location'] = location.pk 

2535 

2536 status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) 

2537 

2538 if status and not self.compare_status(status): 

2539 old_custom_status = self.get_custom_status() 

2540 old_status_logical = self.status 

2541 self.set_status(status) 

2542 tracking_info['status'] = status # may be a custom value 

2543 tracking_info['status_logical'] = self.status # always the logical value 

2544 tracking_info['old_status'] = ( 

2545 old_custom_status if old_custom_status else old_status_logical 

2546 ) 

2547 tracking_info['old_status_logical'] = old_status_logical 

2548 

2549 transferorder = kwargs.pop('transferorder', None) 

2550 if transferorder: 

2551 tracking_info['transferorder'] = transferorder.pk 

2552 

2553 # Optional fields which can be supplied in a 'move' call 

2554 for field in StockItem.optional_transfer_fields(): 

2555 if field in kwargs: 

2556 setattr(self, field, kwargs[field]) 

2557 tracking_info[field] = kwargs[field] 

2558 

2559 self.add_tracking_entry(tracking_code, user, notes=notes, deltas=tracking_info) 

2560 

2561 self.save(add_note=False) 

2562 

2563 # Trigger event for the plugin system 

2564 trigger_event( 

2565 StockEvents.ITEM_MOVED, 

2566 id=self.id, 

2567 old_location=current_location.id if current_location else None, 

2568 new_location=location.id if location else None, 

2569 quantity=quantity, 

2570 ) 

2571 

2572 return True 

2573 

2574 @transaction.atomic 

2575 def updateQuantity(self, quantity): 

2576 """Update stock quantity for this item. 

2577 

2578 If the quantity has reached zero, this StockItem will be deleted. 

2579 

2580 Returns: 

2581 - True if the quantity was saved 

2582 - False if the StockItem was deleted 

2583 """ 

2584 # Do not adjust quantity of a serialized part 

2585 if self.serialized: 

2586 return 

2587 

2588 try: 

2589 self.quantity = Decimal(quantity) 

2590 except (InvalidOperation, ValueError): 

2591 return 

2592 

2593 quantity = max(quantity, 0) 

2594 

2595 self.quantity = quantity 

2596 

2597 if quantity == 0 and self.delete_on_deplete and self.can_delete(): 

2598 self.delete() 

2599 

2600 return False 

2601 

2602 self.save(add_note=False) 

2603 

2604 trigger_event( 

2605 StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity) 

2606 ) 

2607 

2608 return True 

2609 

2610 @transaction.atomic 

2611 def stocktake(self, count, user, **kwargs): 

2612 """Perform item stocktake. 

2613 

2614 Arguments: 

2615 count: The new quantity of the item 

2616 user: The user performing the stocktake 

2617 

2618 Keyword Arguments: 

2619 notes: Optional notes for the stocktake 

2620 status: Optionally adjust the stock status 

2621 location: Optionally set the stock location 

2622 """ 

2623 try: 

2624 count = Decimal(count) 

2625 except InvalidOperation: 

2626 return False 

2627 

2628 if count < 0: 

2629 return False 

2630 

2631 tracking_info = {} 

2632 

2633 location = kwargs.pop('location', None) 

2634 

2635 if location and location != self.location: 

2636 old_location = self.location 

2637 self.location = location 

2638 tracking_info['location'] = location.pk 

2639 tracking_info['old_location'] = old_location.pk if old_location else None 

2640 

2641 status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) 

2642 

2643 if status and not self.compare_status(status): 

2644 old_custom_status = self.get_custom_status() 

2645 old_status_logical = self.status 

2646 self.set_status(status) 

2647 tracking_info['status'] = status # may be a custom value 

2648 tracking_info['status_logical'] = self.status # always the logical value 

2649 tracking_info['old_status'] = ( 

2650 old_custom_status if old_custom_status else old_status_logical 

2651 ) 

2652 tracking_info['old_status_logical'] = old_status_logical 

2653 

2654 if self.serialized or self.updateQuantity(count): 

2655 tracking_info['quantity'] = 1 if self.serialized else float(count) 

2656 

2657 self.stocktake_date = InvenTree.helpers.current_date() 

2658 self.stocktake_user = user 

2659 

2660 # Optional fields which can be supplied in a 'stocktake' call 

2661 for field in StockItem.optional_transfer_fields(): 

2662 if field in kwargs: 

2663 setattr(self, field, kwargs[field]) 

2664 tracking_info[field] = kwargs[field] 

2665 

2666 self.save(add_note=False) 

2667 

2668 self.add_tracking_entry( 

2669 StockHistoryCode.STOCK_COUNT, 

2670 user, 

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

2672 deltas=tracking_info, 

2673 ) 

2674 

2675 trigger_event( 

2676 StockEvents.ITEM_COUNTED, 

2677 'stockitem.counted', 

2678 id=self.id, 

2679 quantity=1 if self.serialized else float(self.quantity), 

2680 ) 

2681 

2682 return True 

2683 

2684 @transaction.atomic 

2685 def add_stock(self, quantity, user, **kwargs): 

2686 """Add a specified quantity of stock to this item. 

2687 

2688 Arguments: 

2689 quantity: The quantity to add 

2690 user: The user performing the action 

2691 

2692 Keyword Arguments: 

2693 notes: Optional notes for the stock addition 

2694 status: Optionally adjust the stock status 

2695 """ 

2696 # Cannot add items to a serialized part 

2697 if self.serialized: 

2698 return False 

2699 

2700 try: 

2701 quantity = Decimal(quantity) 

2702 except InvalidOperation: 

2703 return False 

2704 

2705 # Ignore amounts that do not make sense 

2706 if quantity <= 0: 

2707 return False 

2708 

2709 tracking_info = {} 

2710 

2711 status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) 

2712 

2713 if status and not self.compare_status(status): 

2714 old_custom_status = self.get_custom_status() 

2715 old_status_logical = self.status 

2716 self.set_status(status) 

2717 tracking_info['status'] = status # may be a custom value 

2718 tracking_info['status_logical'] = self.status # always the logical value 

2719 tracking_info['old_status'] = ( 

2720 old_custom_status if old_custom_status else old_status_logical 

2721 ) 

2722 tracking_info['old_status_logical'] = old_status_logical 

2723 

2724 if self.updateQuantity(self.quantity + quantity): 

2725 tracking_info['added'] = float(quantity) 

2726 tracking_info['quantity'] = float(self.quantity) 

2727 

2728 # Optional fields which can be supplied in a 'stocktake' call 

2729 for field in StockItem.optional_transfer_fields(): 

2730 if field in kwargs: 

2731 setattr(self, field, kwargs[field]) 

2732 tracking_info[field] = kwargs[field] 

2733 

2734 self.save(add_note=False) 

2735 

2736 self.add_tracking_entry( 

2737 StockHistoryCode.STOCK_ADD, 

2738 user, 

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

2740 deltas=tracking_info, 

2741 ) 

2742 

2743 return True 

2744 

2745 @transaction.atomic 

2746 def take_stock(self, quantity, user, code=StockHistoryCode.STOCK_REMOVE, **kwargs): 

2747 """Remove the specified quantity from this StockItem. 

2748 

2749 Arguments: 

2750 quantity: The quantity to remove 

2751 user: The user performing the action 

2752 

2753 Keyword Arguments: 

2754 code: The stock history code to use 

2755 notes: Optional notes for the stock removal 

2756 status: Optionally adjust the stock status 

2757 """ 

2758 # Cannot remove items from a serialized part 

2759 if self.serialized: 

2760 return False 

2761 

2762 try: 

2763 quantity = Decimal(quantity) 

2764 except InvalidOperation: 

2765 return False 

2766 

2767 if quantity <= 0: 

2768 return False 

2769 

2770 deltas = {} 

2771 

2772 status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None) 

2773 

2774 if status and not self.compare_status(status): 

2775 old_custom_status = self.get_custom_status() 

2776 old_status_logical = self.status 

2777 self.set_status(status) 

2778 deltas['status'] = status # may be a custom value 

2779 deltas['status_logical'] = self.status # always the logical value 

2780 deltas['old_status'] = ( 

2781 old_custom_status if old_custom_status else old_status_logical 

2782 ) 

2783 deltas['old_status_logical'] = old_status_logical 

2784 

2785 if self.updateQuantity(self.quantity - quantity): 

2786 deltas['removed'] = float(quantity) 

2787 deltas['quantity'] = float(self.quantity) 

2788 

2789 if location := kwargs.get('location'): 

2790 deltas['location'] = location.pk 

2791 

2792 if stockitem := kwargs.get('stockitem'): 

2793 deltas['stockitem'] = stockitem.pk 

2794 

2795 # Optional fields which can be supplied in a 'stocktake' call 

2796 for field in StockItem.optional_transfer_fields(): 

2797 if field in kwargs: 

2798 setattr(self, field, kwargs[field]) 

2799 deltas[field] = kwargs[field] 

2800 

2801 transferorder = kwargs.pop('transferorder', None) 

2802 if transferorder: 

2803 deltas['transferorder'] = transferorder.pk 

2804 

2805 self.save(add_note=False) 

2806 

2807 self.add_tracking_entry( 

2808 code, user, notes=kwargs.get('notes', ''), deltas=deltas 

2809 ) 

2810 

2811 return True 

2812 

2813 def __str__(self): 

2814 """Human friendly name.""" 

2815 if self.part.trackable and self.serial: 

2816 s = f'{self.part.full_name} #{self.serial}' 

2817 else: 

2818 s = f'{InvenTree.helpers.decimal2string(self.quantity)} x {self.part.full_name}' 

2819 

2820 if self.location: 

2821 s += f' @ {self.location.name}' 

2822 

2823 if self.purchase_order: 

2824 s += f' ({self.purchase_order})' 

2825 

2826 return s 

2827 

2828 @transaction.atomic 

2829 def clear_test_results(self, **kwargs): 

2830 """Remove all test results.""" 

2831 # All test results 

2832 results = self.test_results.all() 

2833 results.delete() 

2834 

2835 def getTestResults(self, template=None, test=None, result=None, user=None): 

2836 """Return all test results associated with this StockItem. 

2837 

2838 Optionally can filter results by: 

2839 - Test template ID 

2840 - Test name 

2841 - Test result 

2842 - User 

2843 """ 

2844 results = self.test_results 

2845 

2846 if template: 

2847 results = results.filter(template=template) 

2848 

2849 if test: 

2850 # Filter by test name 

2851 test_key = InvenTree.helpers.generateTestKey(test) 

2852 results = results.filter(template__key=test_key) 

2853 

2854 if result is not None: 

2855 # Filter by test status 

2856 results = results.filter(result=result) 

2857 

2858 if user: 

2859 # Filter by user 

2860 results = results.filter(user=user) 

2861 

2862 return results 

2863 

2864 def testResultMap(self, **kwargs): 

2865 """Return a map of test-results using the test name as the key. 

2866 

2867 Where multiple test results exist for a given name, 

2868 the *most recent* test is used. 

2869 

2870 This map is useful for rendering to a template (e.g. a test report), 

2871 as all named tests are accessible. 

2872 """ 

2873 # Do we wish to include test results from installed items? 

2874 include_installed = kwargs.pop('include_installed', False) 

2875 cascade = kwargs.pop('cascade', False) 

2876 

2877 # Filter results by "date", so that newer results 

2878 # will override older ones. 

2879 results = self.getTestResults(**kwargs).order_by('date') 

2880 

2881 result_map = {} 

2882 

2883 for result in results: 

2884 result_map[result.key] = result 

2885 

2886 if include_installed: 

2887 installed_items = self.get_installed_items(cascade=cascade) 

2888 

2889 for item in installed_items: 

2890 item_results = item.testResultMap() 

2891 

2892 for key in item_results: 

2893 # Results from sub items should not override master ones 

2894 if key not in result_map: 

2895 result_map[key] = item_results[key] 

2896 

2897 return result_map 

2898 

2899 def testResultList(self, **kwargs): 

2900 """Return a list of test-result objects for this StockItem.""" 

2901 return list(self.testResultMap(**kwargs).values()) 

2902 

2903 def requiredTestStatus(self, required_tests=None): 

2904 """Return the status of the tests required for this StockItem. 

2905 

2906 Return: 

2907 A dict containing the following items: 

2908 - total: Number of required tests 

2909 - passed: Number of tests that have passed 

2910 - failed: Number of tests that have failed 

2911 """ 

2912 # All the tests required by the part object 

2913 

2914 if required_tests is None: 

2915 required_tests = self.part.getRequiredTests() 

2916 

2917 results = self.testResultMap() 

2918 

2919 total = len(required_tests) 

2920 passed = 0 

2921 failed = 0 

2922 

2923 for test in required_tests: 

2924 key = InvenTree.helpers.generateTestKey(test.test_name) 

2925 

2926 if key in results: 

2927 result = results[key] 

2928 

2929 if result.result: 

2930 passed += 1 

2931 else: 

2932 failed += 1 

2933 

2934 return {'total': total, 'passed': passed, 'failed': failed} 

2935 

2936 @property 

2937 def required_test_count(self): 

2938 """Return the number of 'required tests' for this StockItem.""" 

2939 return self.part.getRequiredTests().count() 

2940 

2941 def hasRequiredTests(self): 

2942 """Return True if there are any 'required tests' associated with this StockItem.""" 

2943 return self.required_test_count > 0 

2944 

2945 def passedAllRequiredTests(self, required_tests=None): 

2946 """Returns True if this StockItem has passed all required tests.""" 

2947 status = self.requiredTestStatus(required_tests=required_tests) 

2948 

2949 return status['passed'] >= status['total'] 

2950 

2951 

2952@receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log') 

2953def after_delete_stock_item(sender, instance: StockItem, **kwargs): 

2954 """Function to be executed after a StockItem object is deleted.""" 

2955 from part import tasks as part_tasks 

2956 

2957 if InvenTree.ready.isImportingData(): 

2958 return 

2959 

2960 if InvenTree.ready.canAppAccessDatabase(allow_test=True): 

2961 # Run this check in the background 

2962 InvenTree.tasks.offload_task( 

2963 part_tasks.notify_low_stock_if_required, 

2964 instance.part.pk, 

2965 group='notification', 

2966 force_async=True, 

2967 ) 

2968 

2969 if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING): 

2970 # Schedule an update on parent part pricing 

2971 if instance.part: 

2972 instance.part.schedule_pricing_update(create=False) 

2973 

2974 

2975@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') 

2976def after_save_stock_item(sender, instance: StockItem, created, **kwargs): 

2977 """Hook function to be executed after StockItem object is saved/updated.""" 

2978 from part import tasks as part_tasks 

2979 

2980 if not InvenTree.ready.isImportingData(): 

2981 if InvenTree.ready.canAppAccessDatabase(allow_test=True): 

2982 InvenTree.tasks.offload_task( 

2983 part_tasks.notify_low_stock_if_required, 

2984 instance.part.pk, 

2985 group='notification', 

2986 force_async=True, 

2987 ) 

2988 

2989 if InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING): 

2990 if instance.part: 

2991 instance.part.schedule_pricing_update(create=True) 

2992 

2993 

2994class StockItemTracking(InvenTree.models.InvenTreeModel): 

2995 """Stock tracking entry - used for tracking history of a particular StockItem. 

2996 

2997 Attributes: 

2998 item: ForeignKey reference to a particular StockItem 

2999 part: ForeignKey reference to the Part associated with this StockItem 

3000 date: Date that this tracking info was created 

3001 tracking_type: The type of tracking information 

3002 notes: Associated notes (input by user) 

3003 user: The user associated with this tracking info 

3004 deltas: The changes associated with this history item 

3005 

3006 Notes: 

3007 If the underlying stock item is deleted, the "item" field will be set to null, but the tracking information will be retained. 

3008 The tracking data will be removed if the associated part is deleted, as the tracking information is not relevant without the part context. 

3009 """ 

3010 

3011 class Meta: 

3012 """Meta data for the StockItemTracking class.""" 

3013 

3014 verbose_name = _('Stock Item Tracking') 

3015 

3016 @staticmethod 

3017 def get_api_url(): 

3018 """Return API url.""" 

3019 return reverse('api-stock-tracking-list') 

3020 

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

3022 """Ensure that the 'part' link is always correct.""" 

3023 if self.item: 

3024 self.part = self.item.part 

3025 

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

3027 

3028 def get_absolute_url(self): 

3029 """Return url for instance.""" 

3030 return InvenTree.helpers.pui_url(f'/stock/item/{self.item.id}') 

3031 

3032 def label(self): 

3033 """Return label.""" 

3034 if self.tracking_type in StockHistoryCode.keys(): # noqa: SIM118 

3035 return StockHistoryCode.label(self.tracking_type) 

3036 

3037 return getattr(self, 'title', '') 

3038 

3039 tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY) 

3040 

3041 item = models.ForeignKey( 

3042 StockItem, 

3043 on_delete=models.SET_NULL, 

3044 null=True, 

3045 blank=False, 

3046 related_name='tracking_info', 

3047 ) 

3048 

3049 part = models.ForeignKey( 

3050 'part.part', 

3051 on_delete=models.CASCADE, 

3052 related_name='stock_tracking_info', 

3053 null=True, 

3054 blank=True, 

3055 ) 

3056 

3057 date = models.DateTimeField(auto_now_add=True, editable=False) 

3058 

3059 notes = models.CharField( 

3060 blank=True, 

3061 null=True, 

3062 max_length=512, 

3063 verbose_name=_('Notes'), 

3064 help_text=_('Entry notes'), 

3065 ) 

3066 

3067 user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) 

3068 

3069 deltas = models.JSONField(null=True, blank=True) 

3070 

3071 

3072def rename_stock_item_test_result_attachment(instance, filename): 

3073 """Rename test result.""" 

3074 return os.path.join( 

3075 'stock_files', str(instance.stock_item.pk), os.path.basename(filename) 

3076 ) 

3077 

3078 

3079class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel): 

3080 """A StockItemTestResult records results of custom tests against individual StockItem objects. 

3081 

3082 This is useful for tracking unit acceptance tests, and particularly useful when integrated 

3083 with automated testing setups. 

3084 

3085 Multiple results can be recorded against any given test, allowing tests to be run many times. 

3086 

3087 Attributes: 

3088 stock_item: Link to StockItem 

3089 template: Link to TestTemplate 

3090 result: Test result value (pass / fail / etc) 

3091 value: Recorded test output value (optional) 

3092 attachment: Link to StockItem attachment (optional) 

3093 notes: Extra user notes related to the test (optional) 

3094 test_station: the name of the test station where the test was performed 

3095 started_datetime: Date when the test was started 

3096 finished_datetime: Date when the test was finished 

3097 user: User who uploaded the test result 

3098 date: Date the test result was recorded 

3099 """ 

3100 

3101 class Meta: 

3102 """Meta data for the StockItemTestResult class.""" 

3103 

3104 verbose_name = _('Stock Item Test Result') 

3105 

3106 def __str__(self): 

3107 """Return string representation.""" 

3108 return f'{self.test_name} - {self.result}' 

3109 

3110 @staticmethod 

3111 def get_api_url(): 

3112 """Return API url.""" 

3113 return reverse('api-stock-test-result-list') 

3114 

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

3116 """Validate result is unique before saving.""" 

3117 super().clean() 

3118 super().validate_unique() 

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

3120 

3121 def clean(self): 

3122 """Make sure all values - including for templates - are provided.""" 

3123 super().clean() 

3124 

3125 # If this test result corresponds to a template, check the requirements of the template 

3126 try: 

3127 template = self.template 

3128 except PartModels.PartTestTemplate.DoesNotExist: 

3129 template = None 

3130 

3131 if not template: 

3132 raise ValidationError({'template': _('Test template does not exist')}) 

3133 

3134 if template.requires_value and not self.value: 

3135 raise ValidationError({'value': _('Value must be provided for this test')}) 

3136 

3137 if template.requires_attachment and not self.attachment: 

3138 raise ValidationError({ 

3139 'attachment': _('Attachment must be uploaded for this test') 

3140 }) 

3141 

3142 if choices := template.get_choices(): 

3143 if self.value not in choices: 

3144 raise ValidationError({'value': _('Invalid value for this test')}) 

3145 

3146 @property 

3147 def key(self): 

3148 """Return key for test.""" 

3149 return InvenTree.helpers.generateTestKey(self.test_name) 

3150 

3151 stock_item = models.ForeignKey( 

3152 StockItem, on_delete=models.CASCADE, related_name='test_results' 

3153 ) 

3154 

3155 @property 

3156 def test_name(self): 

3157 """Return the test name of the associated test template.""" 

3158 return self.template.test_name 

3159 

3160 template = models.ForeignKey( 

3161 'part.parttesttemplate', 

3162 on_delete=models.CASCADE, 

3163 blank=False, 

3164 related_name='test_results', 

3165 ) 

3166 

3167 result = models.BooleanField( 

3168 default=False, verbose_name=_('Result'), help_text=_('Test result') 

3169 ) 

3170 

3171 value = models.CharField( 

3172 blank=True, 

3173 max_length=500, 

3174 verbose_name=_('Value'), 

3175 help_text=_('Test output value'), 

3176 ) 

3177 

3178 attachment = models.FileField( 

3179 null=True, 

3180 blank=True, 

3181 upload_to=rename_stock_item_test_result_attachment, 

3182 verbose_name=_('Attachment'), 

3183 help_text=_('Test result attachment'), 

3184 ) 

3185 

3186 notes = models.CharField( 

3187 blank=True, max_length=500, verbose_name=_('Notes'), help_text=_('Test notes') 

3188 ) 

3189 

3190 user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) 

3191 

3192 test_station = models.CharField( 

3193 blank=True, 

3194 max_length=500, 

3195 verbose_name=_('Test station'), 

3196 help_text=_('The identifier of the test station where the test was performed'), 

3197 ) 

3198 

3199 started_datetime = models.DateTimeField( 

3200 blank=True, 

3201 null=True, 

3202 verbose_name=_('Started'), 

3203 help_text=_('The timestamp of the test start'), 

3204 ) 

3205 

3206 finished_datetime = models.DateTimeField( 

3207 blank=True, 

3208 null=True, 

3209 verbose_name=_('Finished'), 

3210 help_text=_('The timestamp of the test finish'), 

3211 ) 

3212 

3213 date = models.DateTimeField( 

3214 default=InvenTree.helpers.current_time, verbose_name=_('Date') 

3215 )