Coverage for src/backend/InvenTree/stock/serializers.py: 45%

598 statements  

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

1"""JSON serializers for Stock app.""" 

2 

3from datetime import timedelta 

4from decimal import Decimal 

5 

6from django.core.exceptions import ValidationError as DjangoValidationError 

7from django.db import transaction 

8from django.db.models import BooleanField, Case, Count, Prefetch, Q, Value, When 

9from django.db.models.functions import Coalesce 

10from django.utils.translation import gettext_lazy as _ 

11 

12import structlog 

13from drf_spectacular.utils import extend_schema_field 

14from rest_framework import serializers 

15from rest_framework.serializers import ValidationError 

16from sql_util.utils import SubqueryCount, SubquerySum 

17 

18import build.models 

19import common.filters 

20import company.models 

21import company.serializers as company_serializers 

22import InvenTree.helpers 

23import InvenTree.ready 

24import InvenTree.serializers 

25import order.models 

26import part.filters as part_filters 

27import part.models as part_models 

28import part.serializers as part_serializers 

29import stock.filters 

30import stock.status_codes 

31from common.settings import get_global_setting 

32from generic.states.fields import InvenTreeCustomStatusSerializerMixin 

33from importer.registry import register_importer 

34from InvenTree.mixins import DataImportExportSerializerMixin 

35from InvenTree.serializers import ( 

36 CustomStatusSerializerMixin, 

37 InvenTreeCurrencySerializer, 

38 InvenTreeDecimalField, 

39 OptionalField, 

40 TreePathSerializer, 

41) 

42from users.serializers import UserSerializer 

43 

44from .models import ( 

45 StockItem, 

46 StockItemTestResult, 

47 StockItemTracking, 

48 StockLocation, 

49 StockLocationType, 

50) 

51 

52logger = structlog.get_logger('inventree') 

53 

54 

55class GenerateBatchCodeSerializer(serializers.Serializer): 

56 """Serializer for generating a batch code. 

57 

58 Any of the provided write-only fields can be used for additional context. 

59 """ 

60 

61 class Meta: 

62 """Metaclass options.""" 

63 

64 fields = [ 

65 'batch_code', 

66 'build_order', 

67 'item', 

68 'location', 

69 'part', 

70 'purchase_order', 

71 'quantity', 

72 ] 

73 

74 read_only_fields = ['batch_code'] 

75 

76 write_only_fields = [ 

77 'build_order', 

78 'item', 

79 'location', 

80 'part', 

81 'purchase_order', 

82 'quantity', 

83 ] 

84 

85 batch_code = serializers.CharField( 

86 read_only=True, help_text=_('Generated batch code'), label=_('Batch Code') 

87 ) 

88 

89 build_order = serializers.PrimaryKeyRelatedField( 

90 queryset=build.models.Build.objects.all(), 

91 many=False, 

92 required=False, 

93 allow_null=True, 

94 label=_('Build Order'), 

95 help_text=_('Select build order'), 

96 ) 

97 

98 item = serializers.PrimaryKeyRelatedField( 

99 queryset=StockItem.objects.all(), 

100 many=False, 

101 required=False, 

102 allow_null=True, 

103 label=_('Stock Item'), 

104 help_text=_('Select stock item to generate batch code for'), 

105 ) 

106 

107 location = serializers.PrimaryKeyRelatedField( 

108 queryset=StockLocation.objects.all(), 

109 many=False, 

110 required=False, 

111 allow_null=True, 

112 label=_('Location'), 

113 help_text=_('Select location to generate batch code for'), 

114 ) 

115 

116 part = serializers.PrimaryKeyRelatedField( 

117 queryset=part_models.Part.objects.all(), 

118 many=False, 

119 required=False, 

120 allow_null=True, 

121 label=_('Part'), 

122 help_text=_('Select part to generate batch code for'), 

123 ) 

124 

125 purchase_order = serializers.PrimaryKeyRelatedField( 

126 queryset=order.models.PurchaseOrder.objects.all(), 

127 many=False, 

128 required=False, 

129 allow_null=True, 

130 label=_('Purchase Order'), 

131 help_text=_('Select purchase order'), 

132 ) 

133 

134 quantity = serializers.FloatField( 

135 required=False, 

136 allow_null=True, 

137 label=_('Quantity'), 

138 help_text=_('Enter quantity for batch code'), 

139 ) 

140 

141 

142class GenerateSerialNumberSerializer(serializers.Serializer): 

143 """Serializer for generating one or multiple serial numbers. 

144 

145 Any of the provided write-only fields can be used for additional context. 

146 

147 Note that in the case where multiple serial numbers are required, 

148 the "serial_number" field will return a string with multiple serial numbers 

149 separated by a comma. 

150 """ 

151 

152 class Meta: 

153 """Metaclass options.""" 

154 

155 fields = ['serial_number', 'part', 'quantity'] 

156 

157 read_only_fields = ['serial_number'] 

158 

159 write_only_fields = ['part', 'quantity'] 

160 

161 serial_number = serializers.CharField( 

162 read_only=True, 

163 allow_null=True, 

164 help_text=_('Generated serial number'), 

165 label=_('Serial Number'), 

166 ) 

167 

168 part = serializers.PrimaryKeyRelatedField( 

169 queryset=part_models.Part.objects.all(), 

170 many=False, 

171 required=False, 

172 allow_null=True, 

173 label=_('Part'), 

174 help_text=_('Select part to generate serial number for'), 

175 ) 

176 

177 quantity = serializers.IntegerField( 

178 required=False, 

179 allow_null=False, 

180 default=1, 

181 label=_('Quantity'), 

182 help_text=_('Quantity of serial numbers to generate'), 

183 ) 

184 

185 

186class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

187 """Provides a brief serializer for a StockLocation object.""" 

188 

189 class Meta: 

190 """Metaclass options.""" 

191 

192 model = StockLocation 

193 fields = ['pk', 'name', 'pathstring'] 

194 

195 

196@register_importer() 

197class StockItemTestResultSerializer( 

198 InvenTree.serializers.FilterableSerializerMixin, 

199 DataImportExportSerializerMixin, 

200 InvenTree.serializers.InvenTreeModelSerializer, 

201): 

202 """Serializer for the StockItemTestResult model.""" 

203 

204 class Meta: 

205 """Metaclass options.""" 

206 

207 model = StockItemTestResult 

208 fields = [ 

209 'pk', 

210 'stock_item', 

211 'result', 

212 'value', 

213 'attachment', 

214 'notes', 

215 'test_station', 

216 'started_datetime', 

217 'finished_datetime', 

218 'user', 

219 'user_detail', 

220 'date', 

221 'template', 

222 'template_detail', 

223 ] 

224 read_only_fields = ['pk'] 

225 

226 def __init__(self, *args, **kwargs): 

227 """Handle custom initialization for the serializer.""" 

228 super().__init__(*args, **kwargs) 

229 

230 if not self._is_importing: 230 ↛ exitline 230 didn't return from function '__init__' because the condition on line 230 was always true

231 # Unless we are importing data, mark the 'user' and 'date' fields as read-only 

232 self.fields['user'].read_only = True 

233 self.fields['date'].read_only = True 

234 

235 user_detail = OptionalField( 

236 serializer_class=UserSerializer, 

237 serializer_kwargs={'source': 'user', 'read_only': True, 'allow_null': True}, 

238 prefetch_fields=['user'], 

239 ) 

240 

241 template = serializers.PrimaryKeyRelatedField( 

242 queryset=part_models.PartTestTemplate.objects.all(), 

243 many=False, 

244 required=False, 

245 allow_null=True, 

246 help_text=_('Template'), 

247 label=_('Test template for this result'), 

248 ) 

249 

250 template_detail = OptionalField( 

251 serializer_class=part_serializers.PartTestTemplateSerializer, 

252 serializer_kwargs={'source': 'template', 'read_only': True, 'allow_null': True}, 

253 prefetch_fields=['template'], 

254 ) 

255 

256 attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField( 

257 required=False, 

258 allow_null=True, 

259 label=_('Attachment'), 

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

261 ) 

262 

263 def validate(self, data): 

264 """Validate the test result data.""" 

265 stock_item = data['stock_item'] 

266 template = data.get('template', None) 

267 test_name = None 

268 

269 if not template: 

270 # To support legacy API, we can accept a test name instead of a template 

271 # In such a case, we use the test name to lookup the appropriate template 

272 request_data = self.context['request'].data 

273 

274 if type(request_data) is list and len(request_data) > 0: 

275 request_data = request_data[0] 

276 

277 test_name = request_data.get('test', test_name) 

278 

279 test_key = InvenTree.helpers.generateTestKey(test_name) 

280 

281 ancestors = stock_item.part.get_ancestors(include_self=True) 

282 

283 # Find a template based on name 

284 if template := part_models.PartTestTemplate.objects.filter( 

285 part__tree_id=stock_item.part.tree_id, part__in=ancestors, key=test_key 

286 ).first(): 

287 data['template'] = template 

288 else: 

289 raise ValidationError({ 

290 'test': _('No matching test found for this part') 

291 }) 

292 

293 if not template: 

294 raise ValidationError(_('Template ID or test name must be provided')) 

295 

296 data = super().validate(data) 

297 

298 started = data.get('started_datetime') 

299 finished = data.get('finished_datetime') 

300 

301 if started is not None and finished is not None and started > finished: 

302 raise ValidationError({ 

303 'finished_datetime': _( 

304 'The test finished time cannot be earlier than the test started time' 

305 ) 

306 }) 

307 return data 

308 

309 

310@register_importer() 

311class StockItemSerializer( 

312 CustomStatusSerializerMixin, 

313 InvenTree.serializers.FilterableSerializerMixin, 

314 DataImportExportSerializerMixin, 

315 InvenTreeCustomStatusSerializerMixin, 

316 InvenTree.serializers.InvenTreeTaggitSerializer, 

317 InvenTree.serializers.InvenTreeModelSerializer, 

318): 

319 """Serializer for a StockItem. 

320 

321 - Includes serialization for the linked part 

322 - Includes serialization for the item location 

323 """ 

324 

325 export_exclude_fields = ['tags', 'tracking_items'] 

326 

327 export_child_fields = [ 

328 'part_detail.name', 

329 'part_detail.description', 

330 'part_detail.IPN', 

331 'part_detail.revision', 

332 'part_detail.pricing_min', 

333 'part_detail.pricing_max', 

334 'location_detail.name', 

335 'location_detail.pathstring', 

336 'supplier_part_detail.SKU', 

337 'supplier_part_detail.MPN', 

338 ] 

339 

340 import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size'] 

341 

342 class Meta: 

343 """Metaclass options.""" 

344 

345 model = StockItem 

346 fields = [ 

347 'pk', 

348 'part', 

349 'quantity', 

350 'serial', 

351 'batch', 

352 'location', 

353 'belongs_to', 

354 'build', 

355 'consumed_by', 

356 'customer', 

357 'delete_on_deplete', 

358 'expiry_date', 

359 'in_stock', 

360 'is_building', 

361 'link', 

362 'notes', 

363 'owner', 

364 'packaging', 

365 'parent', 

366 'purchase_order', 

367 'purchase_order_reference', 

368 'sales_order', 

369 'sales_order_reference', 

370 'status', 

371 'status_text', 

372 'status_custom_key', 

373 'supplier_part', 

374 'SKU', 

375 'MPN', 

376 'barcode_hash', 

377 'creation_date', 

378 'stocktake_date', 

379 'updated', 

380 'purchase_price', 

381 'purchase_price_currency', 

382 'use_pack_size', 

383 'serial_numbers', 

384 # Annotated fields 

385 'allocated', 

386 'expired', 

387 'installed_items', 

388 'child_items', 

389 'stale', 

390 # Optional fields (FK relationships) 

391 'location_detail', 

392 'location_path', 

393 'part_detail', 

394 'supplier_part_detail', 

395 'tags', 

396 'tests', 

397 'tracking_items', 

398 ] 

399 read_only_fields = [ 

400 'allocated', 

401 'barcode_hash', 

402 'creation_date', 

403 'stocktake_date', 

404 'stocktake_user', 

405 'updated', 

406 ] 

407 """ 

408 These fields are read-only in this context. 

409 They can be updated by accessing the appropriate API endpoints 

410 """ 

411 extra_kwargs = { 

412 'use_pack_size': {'write_only': True}, 

413 'serial_numbers': {'write_only': True}, 

414 } 

415 """ 

416 Fields used when creating a stock item 

417 """ 

418 

419 part = serializers.PrimaryKeyRelatedField( 

420 queryset=part_models.Part.objects.all(), 

421 many=False, 

422 allow_null=False, 

423 help_text=_('Base Part'), 

424 label=_('Part'), 

425 ) 

426 

427 parent = serializers.PrimaryKeyRelatedField( 

428 many=False, 

429 read_only=True, 

430 required=False, 

431 allow_null=True, 

432 label=_('Parent Item'), 

433 help_text=_('Parent stock item'), 

434 ) 

435 

436 location_path = OptionalField( 

437 serializer_class=TreePathSerializer, 

438 serializer_kwargs={ 

439 'source': 'location.get_path', 

440 'extra_fields': ['icon'], 

441 'many': True, 

442 'read_only': True, 

443 'allow_null': True, 

444 }, 

445 default_include=False, 

446 filter_name='path_detail', 

447 ) 

448 

449 in_stock = serializers.BooleanField(read_only=True, label=_('In Stock')) 

450 

451 """ 

452 Field used when creating a stock item 

453 """ 

454 use_pack_size = serializers.BooleanField( 

455 write_only=True, 

456 required=False, 

457 allow_null=True, 

458 help_text=_( 

459 'Use pack size when adding: the quantity defined is the number of packs' 

460 ), 

461 label=_('Use pack size'), 

462 ) 

463 

464 serial_numbers = serializers.CharField( 

465 write_only=True, 

466 required=False, 

467 allow_null=True, 

468 help_text=_('Enter serial numbers for new items'), 

469 ) 

470 

471 def validate_part(self, part): 

472 """Ensure the provided Part instance is valid.""" 

473 if part.virtual: 

474 raise ValidationError(_('Stock item cannot be created for virtual parts')) 

475 

476 return part 

477 

478 def update(self, instance, validated_data): 

479 """Custom update method to pass the user information through to the instance.""" 

480 instance._user = self.context.get('user', None) 

481 

482 status_custom_key = validated_data.pop('status_custom_key', None) 

483 status = validated_data.pop('status', None) 

484 

485 if status_code := status_custom_key or status: 

486 # avoid a second .save() call and perform both status updates at once (to support `old_status` in tracking event) 

487 # by setting the values in validated_data as computed by set_status() 

488 instance.set_status(status_code) 

489 validated_data['status'] = instance.status 

490 validated_data['status_custom_key'] = ( 

491 status_code # for compatibility with custom "leader/follower" concept in super().update() 

492 ) 

493 

494 instance = super().update(instance, validated_data=validated_data) 

495 

496 return instance 

497 

498 @staticmethod 

499 def annotate_queryset(queryset): 

500 """Add some extra annotations to the queryset, performing database queries as efficiently as possible.""" 

501 queryset = queryset.prefetch_related( 

502 'location', 

503 'sales_order', 

504 'purchase_order', 

505 Prefetch( 

506 'part', 

507 queryset=part_models.Part.objects.annotate( 

508 category_default_location=part_filters.annotate_default_location( 

509 'category__' 

510 ) 

511 ).prefetch_related(None), 

512 ), 

513 'parent', 

514 'part__category', 

515 'supplier_part', 

516 'supplier_part__manufacturer_part', 

517 'customer', 

518 'belongs_to', 

519 'sales_order', 

520 'consumed_by', 

521 ).select_related('part', 'part__pricing_data') 

522 

523 # Annotate the queryset with the total allocated to sales orders 

524 queryset = queryset.annotate( 

525 allocated=Coalesce( 

526 SubquerySum('sales_order_allocations__quantity'), Decimal(0) 

527 ) 

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

529 # + Coalesce(SubquerySum('transfer_order_allocations__quantity'), Decimal(0)) 

530 + Coalesce(SubquerySum('allocations__quantity'), Decimal(0)) 

531 ) 

532 

533 # Annotate the queryset with the number of tracking items 

534 queryset = queryset.annotate(tracking_items=SubqueryCount('tracking_info')) 

535 

536 # Add flag to indicate if the StockItem has expired 

537 queryset = queryset.annotate( 

538 expired=Case( 

539 When( 

540 StockItem.get_expired_filter(), 

541 then=Value(True, output_field=BooleanField()), 

542 ), 

543 default=Value(False, output_field=BooleanField()), 

544 ) 

545 ) 

546 

547 # Add flag to indicate if the StockItem is stale 

548 stale_days = get_global_setting('STOCK_STALE_DAYS') 

549 stale_date = InvenTree.helpers.current_date() + timedelta(days=stale_days) 

550 stale_filter = ( 

551 StockItem.IN_STOCK_FILTER 

552 & ~Q(expiry_date=None) 

553 & Q(expiry_date__lt=stale_date) 

554 ) 

555 

556 queryset = queryset.annotate( 

557 stale=Case( 

558 When(stale_filter, then=Value(True, output_field=BooleanField())), 

559 default=Value(False, output_field=BooleanField()), 

560 ) 

561 ) 

562 

563 # Annotate with the total number of "installed items" 

564 queryset = queryset.annotate(installed_items=SubqueryCount('installed_parts')) 

565 

566 # Annotate with the total number of "child items" (split stock items) 

567 queryset = queryset.annotate(child_items=SubqueryCount('children')) 

568 

569 return queryset 

570 

571 SKU = serializers.CharField( 

572 source='supplier_part.SKU', 

573 read_only=True, 

574 label=_('Supplier Part Number'), 

575 allow_null=True, 

576 ) 

577 

578 MPN = serializers.CharField( 

579 source='supplier_part.manufacturer_part.MPN', 

580 read_only=True, 

581 label=_('Manufacturer Part Number'), 

582 allow_null=True, 

583 ) 

584 

585 # Optional detail fields, which can be appended via query parameters 

586 supplier_part_detail = OptionalField( 

587 serializer_class=company_serializers.SupplierPartSerializer, 

588 serializer_kwargs={ 

589 'label': _('Supplier Part'), 

590 'source': 'supplier_part', 

591 'brief': True, 

592 'supplier_detail': False, 

593 'manufacturer_detail': False, 

594 'part_detail': False, 

595 'many': False, 

596 'read_only': True, 

597 'allow_null': True, 

598 }, 

599 default_include=False, 

600 prefetch_fields=[ 

601 'supplier_part__supplier', 

602 'supplier_part__purchase_order_line_items', 

603 'supplier_part__manufacturer_part__manufacturer', 

604 ], 

605 ) 

606 

607 part_detail = OptionalField( 

608 serializer_class=part_serializers.PartBriefSerializer, 

609 serializer_kwargs={ 

610 'label': _('Part'), 

611 'source': 'part', 

612 'many': False, 

613 'read_only': True, 

614 'allow_null': True, 

615 }, 

616 default_include=True, 

617 ) 

618 

619 location_detail = OptionalField( 

620 serializer_class=LocationBriefSerializer, 

621 serializer_kwargs={ 

622 'label': _('Location'), 

623 'source': 'location', 

624 'many': False, 

625 'read_only': True, 

626 'allow_null': True, 

627 }, 

628 default_include=False, 

629 prefetch_fields=['location'], 

630 ) 

631 

632 tests = OptionalField( 

633 serializer_class=StockItemTestResultSerializer, 

634 serializer_kwargs={ 

635 'source': 'test_results', 

636 'many': True, 

637 'read_only': True, 

638 'allow_null': True, 

639 }, 

640 default_include=False, 

641 prefetch_fields=[ 

642 'test_results', 

643 'test_results__user', 

644 'test_results__template', 

645 ], 

646 ) 

647 

648 quantity = InvenTreeDecimalField() 

649 

650 # Annotated fields 

651 allocated = serializers.FloatField( 

652 read_only=True, allow_null=True, label=_('Allocated Quantity') 

653 ) 

654 expired = serializers.BooleanField( 

655 read_only=True, allow_null=True, label=_('Expired') 

656 ) 

657 installed_items = serializers.IntegerField( 

658 read_only=True, allow_null=True, label=_('Installed Items') 

659 ) 

660 child_items = serializers.IntegerField( 

661 read_only=True, allow_null=True, label=_('Child Items') 

662 ) 

663 stale = serializers.BooleanField(read_only=True, allow_null=True, label=_('Stale')) 

664 tracking_items = serializers.IntegerField( 

665 read_only=True, allow_null=True, label=_('Tracking Items') 

666 ) 

667 

668 purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( 

669 label=_('Purchase Price'), 

670 allow_null=True, 

671 help_text=_('Purchase price of this stock item, per unit or pack'), 

672 ) 

673 

674 purchase_price_currency = InvenTreeCurrencySerializer( 

675 help_text=_('Purchase currency of this stock item') 

676 ) 

677 

678 purchase_order_reference = serializers.CharField( 

679 source='purchase_order.reference', read_only=True, allow_null=True 

680 ) 

681 

682 sales_order_reference = serializers.CharField( 

683 source='sales_order.reference', read_only=True, allow_null=True 

684 ) 

685 

686 tags = common.filters.enable_tags_filter() 

687 

688 

689class SerializeStockItemSerializer(serializers.Serializer): 

690 """A DRF serializer for "serializing" a StockItem. 

691 

692 (Sorry for the confusing naming...) 

693 

694 Here, "serializing" means splitting out a single StockItem, 

695 into multiple single-quantity items with an assigned serial number 

696 

697 Note: The base StockItem object is provided to the serializer context 

698 """ 

699 

700 class Meta: 

701 """Metaclass options.""" 

702 

703 fields = ['quantity', 'serial_numbers', 'destination', 'notes'] 

704 

705 quantity = serializers.IntegerField( 

706 min_value=0, 

707 required=True, 

708 label=_('Quantity'), 

709 help_text=_('Enter number of stock items to serialize'), 

710 ) 

711 

712 def validate_quantity(self, quantity): 

713 """Validate that the quantity value is correct.""" 

714 item = self.context.get('item') 

715 

716 if not item: 716 ↛ 719line 716 didn't jump to line 719 because the condition on line 716 was always true

717 raise ValidationError(_('No stock item provided')) 

718 

719 if quantity < 0: 

720 raise ValidationError(_('Quantity must be greater than zero')) 

721 

722 if quantity > item.quantity: 

723 q = item.quantity 

724 raise ValidationError( 

725 _(f'Quantity must not exceed available stock quantity ({q})') 

726 ) 

727 

728 return quantity 

729 

730 serial_numbers = serializers.CharField( 

731 label=_('Serial Numbers'), 

732 help_text=_('Enter serial numbers for new items'), 

733 allow_blank=False, 

734 required=True, 

735 ) 

736 

737 destination = serializers.PrimaryKeyRelatedField( 

738 queryset=StockLocation.objects.all(), 

739 many=False, 

740 required=True, 

741 allow_null=False, 

742 label=_('Location'), 

743 help_text=_('Destination stock location'), 

744 ) 

745 

746 notes = serializers.CharField( 

747 required=False, 

748 allow_blank=True, 

749 label=_('Notes'), 

750 help_text=_('Optional note field'), 

751 ) 

752 

753 def validate(self, data): 

754 """Check that the supplied serial numbers are valid.""" 

755 data = super().validate(data) 

756 

757 item = self.context.get('item') 

758 

759 if not item: 

760 raise ValidationError(_('No stock item provided')) 

761 

762 if not item.part.trackable: 

763 raise ValidationError(_('Serial numbers cannot be assigned to this part')) 

764 

765 # Ensure the serial numbers are valid! 

766 quantity = data['quantity'] 

767 serial_numbers = data['serial_numbers'] 

768 

769 try: 

770 serials = InvenTree.helpers.extract_serial_numbers( 

771 serial_numbers, 

772 quantity, 

773 item.part.get_latest_serial_number(), 

774 part=item.part, 

775 ) 

776 except DjangoValidationError as e: 

777 raise ValidationError({'serial_numbers': e.messages}) 

778 

779 existing = item.part.find_conflicting_serial_numbers(serials) 

780 

781 if len(existing) > 0: 

782 exists = ','.join([str(x) for x in existing]) 

783 error = _('Serial numbers already exist') + ': ' + exists 

784 

785 raise ValidationError({'serial_numbers': error}) 

786 

787 return data 

788 

789 def save(self) -> list[StockItem]: 

790 """Serialize the provided StockItem. 

791 

792 Returns: 

793 A list of StockItem objects that were created as a result of the serialization. 

794 """ 

795 item = self.context.get('item') 

796 

797 if not item: 

798 raise ValidationError(_('No stock item provided')) 

799 

800 request = self.context.get('request') 

801 user = request.user if request else None 

802 

803 data = self.validated_data 

804 

805 serials = InvenTree.helpers.extract_serial_numbers( 

806 data['serial_numbers'], 

807 data['quantity'], 

808 item.part.get_latest_serial_number(), 

809 part=item.part, 

810 ) 

811 

812 return ( 

813 item.serializeStock( 

814 data['quantity'], 

815 serials, 

816 user=user, 

817 notes=data.get('notes', ''), 

818 location=data['destination'], 

819 ) 

820 or [] 

821 ) 

822 

823 

824class InstallStockItemSerializer(serializers.Serializer): 

825 """Serializer for installing a stock item into a given part.""" 

826 

827 stock_item = serializers.PrimaryKeyRelatedField( 

828 queryset=StockItem.objects.all(), 

829 many=False, 

830 required=True, 

831 allow_null=False, 

832 label=_('Stock Item'), 

833 help_text=_('Select stock item to install'), 

834 ) 

835 

836 quantity = serializers.IntegerField( 

837 min_value=1, 

838 default=1, 

839 required=False, 

840 label=_('Quantity to Install'), 

841 help_text=_('Enter the quantity of items to install'), 

842 ) 

843 

844 note = serializers.CharField( 

845 label=_('Note'), 

846 help_text=_('Add transaction note (optional)'), 

847 required=False, 

848 allow_blank=True, 

849 ) 

850 

851 def validate_quantity(self, quantity): 

852 """Validate the quantity value.""" 

853 if quantity < 1: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true

854 raise ValidationError(_('Quantity to install must be at least 1')) 

855 

856 return quantity 

857 

858 def validate_stock_item(self, stock_item): 

859 """Validate the selected stock item.""" 

860 if not stock_item.in_stock: 

861 # StockItem must be in stock to be "installed" 

862 raise ValidationError(_('Stock item is unavailable')) 

863 

864 parent_item = self.context['item'] 

865 parent_part = parent_item.part 

866 

867 if get_global_setting( 

868 'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False 

869 ): 

870 # Check if the selected part is in the Bill of Materials of the parent item 

871 if not parent_part.check_if_part_in_bom(stock_item.part): 

872 raise ValidationError( 

873 _('Selected part is not in the Bill of Materials') 

874 ) 

875 

876 return stock_item 

877 

878 def validate(self, data): 

879 """Ensure that the provided dataset is valid.""" 

880 stock_item = data['stock_item'] 

881 

882 quantity = data.get('quantity', stock_item.quantity) 

883 

884 if quantity > stock_item.quantity: 

885 raise ValidationError({ 

886 'quantity': _('Quantity to install must not exceed available quantity') 

887 }) 

888 

889 return data 

890 

891 def save(self): 

892 """Install the selected stock item into this one.""" 

893 data = self.validated_data 

894 

895 stock_item = data['stock_item'] 

896 quantity_to_install = data.get('quantity', stock_item.quantity) 

897 note = data.get('note', '') 

898 

899 parent_item = self.context['item'] 

900 request = self.context['request'] 

901 

902 parent_item.installStockItem( 

903 stock_item, quantity_to_install, request.user, note 

904 ) 

905 

906 

907class UninstallStockItemSerializer(serializers.Serializer): 

908 """API serializers for uninstalling an installed item from a stock item.""" 

909 

910 class Meta: 

911 """Metaclass options.""" 

912 

913 fields = ['location', 'note'] 

914 

915 location = serializers.PrimaryKeyRelatedField( 

916 queryset=StockLocation.objects.all(), 

917 many=False, 

918 required=True, 

919 allow_null=False, 

920 label=_('Location'), 

921 help_text=_('Destination location for uninstalled item'), 

922 ) 

923 

924 note = serializers.CharField( 

925 label=_('Notes'), 

926 help_text=_('Add transaction note (optional)'), 

927 required=False, 

928 allow_blank=True, 

929 ) 

930 

931 def save(self): 

932 """Uninstall stock item.""" 

933 item = self.context.get('item') 

934 

935 if not item: 

936 raise ValidationError(_('No stock item provided')) 

937 

938 data = self.validated_data 

939 request = self.context['request'] 

940 

941 location = data['location'] 

942 

943 note = data.get('note', '') 

944 

945 item.uninstall_into_location(location, request.user, note) 

946 

947 

948class ConvertStockItemSerializer(serializers.Serializer): 

949 """DRF serializer class for converting a StockItem to a valid variant part.""" 

950 

951 class Meta: 

952 """Metaclass options.""" 

953 

954 fields = ['part'] 

955 

956 part = serializers.PrimaryKeyRelatedField( 

957 queryset=part_models.Part.objects.all(), 

958 label=_('Part'), 

959 help_text=_('Select part to convert stock item into'), 

960 many=False, 

961 required=True, 

962 allow_null=False, 

963 ) 

964 

965 def validate_part(self, part): 

966 """Ensure that the provided part is a valid option for the stock item.""" 

967 stock_item = self.context['item'] 

968 valid_options = stock_item.part.get_conversion_options() 

969 

970 if part not in valid_options: 

971 raise ValidationError( 

972 _('Selected part is not a valid option for conversion') 

973 ) 

974 

975 return part 

976 

977 def validate(self, data): 

978 """Ensure that the stock item is valid for conversion. 

979 

980 Rules: 

981 - If a SupplierPart is assigned, we cannot convert! 

982 """ 

983 data = super().validate(data) 

984 

985 stock_item = self.context['item'] 

986 

987 if stock_item.supplier_part is not None: 

988 raise ValidationError( 

989 _('Cannot convert stock item with assigned SupplierPart') 

990 ) 

991 

992 return data 

993 

994 def save(self): 

995 """Save the serializer to convert the StockItem to the selected Part.""" 

996 data = self.validated_data 

997 

998 part = data['part'] 

999 

1000 stock_item = self.context['item'] 

1001 request = self.context['request'] 

1002 

1003 stock_item.convert_to_variant(part, request.user) 

1004 

1005 

1006@extend_schema_field( 

1007 serializers.IntegerField( 

1008 help_text='Status key, chosen from the list of StockStatus keys' 

1009 ) 

1010) 

1011class StockStatusCustomSerializer(serializers.ChoiceField): 

1012 """Serializer to allow annotating the schema to use int where custom values may be entered.""" 

1013 

1014 def __init__(self, *args, **kwargs): 

1015 """Initialize the status selector.""" 

1016 if 'choices' not in kwargs: 

1017 kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True) 

1018 

1019 if 'label' not in kwargs: 1019 ↛ 1022line 1019 didn't jump to line 1022 because the condition on line 1019 was always true

1020 kwargs['label'] = _('Status') 

1021 

1022 if 'help_text' not in kwargs: 1022 ↛ 1025line 1022 didn't jump to line 1025 because the condition on line 1022 was always true

1023 kwargs['help_text'] = _('Stock item status code') 

1024 

1025 if InvenTree.ready.isGeneratingSchema(): 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true

1026 kwargs['help_text'] = ( 

1027 kwargs['help_text'] 

1028 + '\n\n' 

1029 + '\n'.join( 

1030 f'* `{value}` - {label}' for value, label in kwargs['choices'] 

1031 ) 

1032 + "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call." 

1033 ) 

1034 

1035 super().__init__(*args, **kwargs) 

1036 

1037 

1038class StockChangeStatusSerializer(serializers.Serializer): 

1039 """Serializer for changing status of multiple StockItem objects.""" 

1040 

1041 class Meta: 

1042 """Metaclass options.""" 

1043 

1044 fields = ['items', 'status', 'note'] 

1045 

1046 items = serializers.PrimaryKeyRelatedField( 

1047 queryset=StockItem.objects.all(), 

1048 many=True, 

1049 required=True, 

1050 allow_null=False, 

1051 label=_('Stock Items'), 

1052 help_text=_('Select stock items to change status'), 

1053 ) 

1054 

1055 def validate_items(self, items): 

1056 """Validate the selected stock items.""" 

1057 if len(items) == 0: 1057 ↛ 1060line 1057 didn't jump to line 1060 because the condition on line 1057 was always true

1058 raise ValidationError(_('No stock items selected')) 

1059 

1060 return items 

1061 

1062 status = StockStatusCustomSerializer( 

1063 default=stock.status_codes.StockStatus.OK.value 

1064 ) 

1065 

1066 note = serializers.CharField( 

1067 label=_('Notes'), 

1068 help_text=_('Add transaction note (optional)'), 

1069 required=False, 

1070 allow_blank=True, 

1071 ) 

1072 

1073 @transaction.atomic 

1074 def save(self): 

1075 """Save the serializer to change the status of the selected stock items.""" 

1076 data = self.validated_data 

1077 

1078 items = data['items'] 

1079 status = data['status'] 

1080 

1081 request = self.context['request'] 

1082 user = getattr(request, 'user', None) 

1083 

1084 note = data.get('note', '') 

1085 

1086 transaction_notes = [] 

1087 

1088 now = InvenTree.helpers.current_time() 

1089 

1090 # Instead of performing database updates for each item, 

1091 # perform bulk database updates (much more efficient) 

1092 

1093 # Pre-cache the custom status values (to reduce DB hits) 

1094 custom_status_codes = StockItem.STATUS_CLASS.custom_values() 

1095 

1096 for item in items: 

1097 # Ignore items which are already in the desired status 

1098 

1099 # Careful check for custom status codes also 

1100 if item.compare_status(status): 

1101 custom_status = item.get_custom_status() 

1102 if status == custom_status or custom_status is None: 

1103 continue 

1104 

1105 deltas = {'status': status} 

1106 

1107 # before save, track old status logical 

1108 deltas['old_status_logical'] = item.status 

1109 

1110 if item.get_custom_status(): 

1111 deltas['old_status'] = item.get_custom_status() 

1112 else: 

1113 deltas['old_status'] = item.status 

1114 

1115 item.set_status(status, custom_values=custom_status_codes) 

1116 item.save(add_note=False) 

1117 

1118 # after save, can track new status_logical 

1119 deltas['status_logical'] = item.status 

1120 

1121 # Create a new transaction note for each item 

1122 transaction_notes.append( 

1123 StockItemTracking( 

1124 item=item, 

1125 tracking_type=stock.status_codes.StockHistoryCode.EDITED.value, 

1126 date=now, 

1127 deltas=deltas, 

1128 user=user, 

1129 notes=note, 

1130 ) 

1131 ) 

1132 

1133 # Create tracking entries 

1134 StockItemTracking.objects.bulk_create(transaction_notes, batch_size=250) 

1135 

1136 

1137class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1138 """Serializer for StockLocationType model.""" 

1139 

1140 class Meta: 

1141 """Serializer metaclass.""" 

1142 

1143 model = StockLocationType 

1144 fields = ['pk', 'name', 'description', 'icon', 'location_count'] 

1145 

1146 read_only_fields = ['location_count'] 

1147 

1148 location_count = serializers.IntegerField(read_only=True, allow_null=True) 

1149 

1150 @staticmethod 

1151 def annotate_queryset(queryset): 

1152 """Add location count to each location type.""" 

1153 return queryset.annotate(location_count=Count('stock_locations')) 

1154 

1155 

1156class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1157 """Serializer for a simple tree view.""" 

1158 

1159 class Meta: 

1160 """Metaclass options.""" 

1161 

1162 model = StockLocation 

1163 fields = [ 

1164 'pk', 

1165 'name', 

1166 'description', 

1167 'pathstring', 

1168 'parent', 

1169 'tree_id', 

1170 'level', 

1171 'icon', 

1172 'structural', 

1173 'sublocations', 

1174 ] 

1175 

1176 sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True) 

1177 

1178 @staticmethod 

1179 def annotate_queryset(queryset): 

1180 """Annotate the queryset with the number of sublocations.""" 

1181 return queryset.annotate(sublocations=stock.filters.annotate_sub_locations()) 

1182 

1183 

1184class LocationDeleteSerializer(serializers.Serializer): 

1185 """Serializer for deleting a stock location.""" 

1186 

1187 class Meta: 

1188 """Metaclass options.""" 

1189 

1190 fields = ['delete_stock_items', 'delete_sub_locations'] 

1191 

1192 delete_stock_items = serializers.BooleanField( 

1193 required=True, 

1194 label=_('Delete Stock Items'), 

1195 help_text=_('Delete all stock items contained within this location'), 

1196 ) 

1197 

1198 delete_sub_locations = serializers.BooleanField( 

1199 required=True, 

1200 label=_('Delete Sublocations'), 

1201 help_text=_('Delete all sub-locations contained within this location'), 

1202 ) 

1203 

1204 

1205@register_importer() 

1206class LocationSerializer( 

1207 InvenTree.serializers.FilterableSerializerMixin, 

1208 DataImportExportSerializerMixin, 

1209 InvenTree.serializers.InvenTreeTaggitSerializer, 

1210 InvenTree.serializers.InvenTreeModelSerializer, 

1211): 

1212 """Detailed information about a stock location.""" 

1213 

1214 import_exclude_fields = [] 

1215 

1216 class Meta: 

1217 """Metaclass options.""" 

1218 

1219 model = StockLocation 

1220 fields = [ 

1221 'pk', 

1222 'barcode_hash', 

1223 'name', 

1224 'level', 

1225 'description', 

1226 'parent', 

1227 'pathstring', 

1228 'path', 

1229 'items', 

1230 'sublocations', 

1231 'owner', 

1232 'icon', 

1233 'custom_icon', 

1234 'structural', 

1235 'external', 

1236 'location_type', 

1237 # Optional fields 

1238 'location_type_detail', 

1239 'tags', 

1240 'parameters', 

1241 ] 

1242 read_only_fields = ['barcode_hash', 'icon', 'level', 'pathstring'] 

1243 

1244 @staticmethod 

1245 def annotate_queryset(queryset): 

1246 """Annotate extra information to the queryset.""" 

1247 # Annotate the number of stock items which exist in this category (including subcategories) 

1248 

1249 queryset = queryset.prefetch_related('tags') 

1250 

1251 queryset = queryset.annotate( 

1252 items=stock.filters.annotate_location_items(), 

1253 sublocations=stock.filters.annotate_sub_locations(), 

1254 ) 

1255 

1256 return queryset 

1257 

1258 parent = serializers.PrimaryKeyRelatedField( 

1259 queryset=StockLocation.objects.all(), 

1260 many=False, 

1261 allow_null=True, 

1262 required=False, 

1263 label=_('Parent Location'), 

1264 help_text=_('Parent stock location'), 

1265 ) 

1266 

1267 items = serializers.IntegerField(read_only=True, label=_('Stock Items')) 

1268 

1269 sublocations = serializers.IntegerField(read_only=True, label=_('Sublocations')) 

1270 

1271 level = serializers.IntegerField(read_only=True) 

1272 

1273 tags = common.filters.enable_tags_filter() 

1274 

1275 path = OptionalField( 

1276 serializer_class=TreePathSerializer, 

1277 serializer_kwargs={ 

1278 'many': True, 

1279 'source': 'get_path', 

1280 'extra_fields': ['icon'], 

1281 'read_only': True, 

1282 'allow_null': True, 

1283 }, 

1284 default_include=False, 

1285 filter_name='path_detail', 

1286 ) 

1287 

1288 parameters = common.filters.enable_parameters_filter() 

1289 

1290 # explicitly set this field, so it gets included for AutoSchema 

1291 icon = serializers.CharField(read_only=True) 

1292 

1293 # Detail for location type 

1294 location_type_detail = StockLocationTypeSerializer( 

1295 source='location_type', read_only=True, allow_null=True, many=False 

1296 ) 

1297 

1298 

1299@register_importer() 

1300class StockTrackingSerializer( 

1301 InvenTree.serializers.FilterableSerializerMixin, 

1302 DataImportExportSerializerMixin, 

1303 InvenTree.serializers.InvenTreeModelSerializer, 

1304): 

1305 """Serializer for StockItemTracking model.""" 

1306 

1307 class Meta: 

1308 """Metaclass options.""" 

1309 

1310 model = StockItemTracking 

1311 fields = [ 

1312 'pk', 

1313 'item', 

1314 'item_detail', 

1315 'part', 

1316 'part_detail', 

1317 'date', 

1318 'deltas', 

1319 'label', 

1320 'notes', 

1321 'tracking_type', 

1322 'user', 

1323 'user_detail', 

1324 ] 

1325 read_only_fields = ['date', 'part', 'user', 'label', 'tracking_type'] 

1326 

1327 label = serializers.CharField(read_only=True) 

1328 

1329 item_detail = OptionalField( 

1330 serializer_class=StockItemSerializer, 

1331 serializer_kwargs={ 

1332 'source': 'item', 

1333 'many': False, 

1334 'read_only': True, 

1335 'allow_null': True, 

1336 }, 

1337 prefetch_fields=['item', 'item__part'], 

1338 ) 

1339 

1340 part_detail = OptionalField( 

1341 serializer_class=part_serializers.PartBriefSerializer, 

1342 serializer_kwargs={ 

1343 'source': 'part', 

1344 'many': False, 

1345 'read_only': True, 

1346 'allow_null': True, 

1347 }, 

1348 default_include=False, 

1349 prefetch_fields=['part'], 

1350 ) 

1351 

1352 user_detail = OptionalField( 

1353 serializer_class=UserSerializer, 

1354 serializer_kwargs={ 

1355 'source': 'user', 

1356 'many': False, 

1357 'read_only': True, 

1358 'allow_null': True, 

1359 }, 

1360 prefetch_fields=['user'], 

1361 ) 

1362 

1363 deltas = serializers.JSONField(read_only=True) 

1364 

1365 

1366class StockAssignmentItemSerializer(serializers.Serializer): 

1367 """Serializer for a single StockItem with in StockAssignment request. 

1368 

1369 Here, the particular StockItem is being assigned (manually) to a customer 

1370 

1371 Fields: 

1372 - item: StockItem object 

1373 """ 

1374 

1375 class Meta: 

1376 """Metaclass options.""" 

1377 

1378 fields = ['item'] 

1379 

1380 item = serializers.PrimaryKeyRelatedField( 

1381 queryset=StockItem.objects.all(), 

1382 many=False, 

1383 allow_null=False, 

1384 required=True, 

1385 label=_('Stock Item'), 

1386 ) 

1387 

1388 def validate_item(self, item): 

1389 """Validate item. 

1390 

1391 Ensures: 

1392 - is in stock 

1393 - Is salable 

1394 - Is not allocated 

1395 """ 

1396 # The item must currently be "in stock" 

1397 if not item.in_stock: 

1398 raise ValidationError(_('Item must be in stock')) 

1399 

1400 # The base part must be "salable" 

1401 if not item.part.salable: 

1402 raise ValidationError(_('Part must be salable')) 

1403 

1404 # The item must not be allocated to a sales order 

1405 if item.sales_order_allocations.count() > 0: 

1406 raise ValidationError(_('Item is allocated to a sales order')) 

1407 

1408 # The item must not be allocated to a transfer order 

1409 if item.transfer_order_allocations.count() > 0: 

1410 raise ValidationError(_('Item is allocated to a transfer order')) 

1411 

1412 # The item must not be allocated to a build order 

1413 if item.allocations.count() > 0: 

1414 raise ValidationError(_('Item is allocated to a build order')) 

1415 

1416 return item 

1417 

1418 

1419class StockAssignmentSerializer(serializers.Serializer): 

1420 """Serializer for assigning one (or more) stock items to a customer. 

1421 

1422 This is a manual assignment process, separate for (for example) a Sales Order 

1423 """ 

1424 

1425 class Meta: 

1426 """Metaclass options.""" 

1427 

1428 fields = ['items', 'customer', 'notes'] 

1429 

1430 items = StockAssignmentItemSerializer(many=True, required=True) 

1431 

1432 customer = serializers.PrimaryKeyRelatedField( 

1433 queryset=company.models.Company.objects.all(), 

1434 many=False, 

1435 allow_null=False, 

1436 required=True, 

1437 label=_('Customer'), 

1438 help_text=_('Customer to assign stock items'), 

1439 ) 

1440 

1441 def validate_customer(self, customer): 

1442 """Make sure provided company is customer.""" 

1443 if customer and not customer.is_customer: 

1444 raise ValidationError(_('Selected company is not a customer')) 

1445 

1446 return customer 

1447 

1448 notes = serializers.CharField( 

1449 required=False, 

1450 allow_blank=True, 

1451 label=_('Notes'), 

1452 help_text=_('Stock assignment notes'), 

1453 ) 

1454 

1455 def validate(self, data): 

1456 """Make sure items were provided.""" 

1457 data = super().validate(data) 

1458 

1459 items = data.get('items', []) 

1460 

1461 if len(items) == 0: 

1462 raise ValidationError(_('A list of stock items must be provided')) 

1463 

1464 return data 

1465 

1466 def save(self): 

1467 """Assign stock.""" 

1468 request = self.context['request'] 

1469 

1470 user = getattr(request, 'user', None) 

1471 

1472 data = self.validated_data 

1473 

1474 items = data['items'] 

1475 customer = data['customer'] 

1476 notes = data.get('notes', '') 

1477 

1478 with transaction.atomic(): 

1479 for item in items: 

1480 stock_item = item['item'] 

1481 

1482 stock_item.allocateToCustomer(customer, user=user, notes=notes) 

1483 

1484 

1485class StockMergeItemSerializer(serializers.Serializer): 

1486 """Serializer for a single StockItem within the StockMergeSerializer class. 

1487 

1488 Here, the individual StockItem is being checked for merge compatibility. 

1489 """ 

1490 

1491 class Meta: 

1492 """Metaclass options.""" 

1493 

1494 fields = ['item'] 

1495 

1496 item = serializers.PrimaryKeyRelatedField( 

1497 queryset=StockItem.objects.all(), 

1498 many=False, 

1499 allow_null=False, 

1500 required=True, 

1501 label=_('Stock Item'), 

1502 ) 

1503 

1504 def validate_item(self, item): 

1505 """Make sure item can be merged.""" 

1506 # Check that the stock item is able to be merged 

1507 item.can_merge(raise_error=True) 

1508 

1509 return item 

1510 

1511 

1512class StockMergeSerializer(serializers.Serializer): 

1513 """Serializer for merging two (or more) stock items together.""" 

1514 

1515 class Meta: 

1516 """Metaclass options.""" 

1517 

1518 fields = [ 

1519 'items', 

1520 'location', 

1521 'notes', 

1522 'allow_mismatched_suppliers', 

1523 'allow_mismatched_status', 

1524 ] 

1525 

1526 items = StockMergeItemSerializer(many=True, required=True) 

1527 

1528 location = serializers.PrimaryKeyRelatedField( 

1529 queryset=StockLocation.objects.all(), 

1530 many=False, 

1531 required=True, 

1532 allow_null=False, 

1533 label=_('Location'), 

1534 help_text=_('Destination stock location'), 

1535 ) 

1536 

1537 notes = serializers.CharField( 

1538 required=False, 

1539 allow_blank=True, 

1540 label=_('Notes'), 

1541 help_text=_('Stock merging notes'), 

1542 ) 

1543 

1544 allow_mismatched_suppliers = serializers.BooleanField( 

1545 required=False, 

1546 label=_('Allow mismatched suppliers'), 

1547 help_text=_('Allow stock items with different supplier parts to be merged'), 

1548 ) 

1549 

1550 allow_mismatched_status = serializers.BooleanField( 

1551 required=False, 

1552 label=_('Allow mismatched status'), 

1553 help_text=_('Allow stock items with different status codes to be merged'), 

1554 ) 

1555 

1556 def validate(self, data): 

1557 """Make sure all needed values are provided and that the items can be merged.""" 

1558 data = super().validate(data) 

1559 

1560 items = data['items'] 

1561 

1562 if len(items) < 2: 

1563 raise ValidationError(_('At least two stock items must be provided')) 

1564 

1565 unique_items = set() 

1566 

1567 # The "base item" is the first item 

1568 base_item = items[0]['item'] 

1569 

1570 data['base_item'] = base_item 

1571 

1572 # Ensure stock items are unique! 

1573 for element in items: 

1574 item = element['item'] 

1575 

1576 if item.pk in unique_items: 

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

1578 

1579 unique_items.add(item.pk) 

1580 

1581 # Checks from here refer to the "base_item" 

1582 if item == base_item: 

1583 continue 

1584 

1585 # Check that this item can be merged with the base_item 

1586 item.can_merge( 

1587 raise_error=True, 

1588 other=base_item, 

1589 allow_mismatched_suppliers=data.get( 

1590 'allow_mismatched_suppliers', False 

1591 ), 

1592 allow_mismatched_status=data.get('allow_mismatched_status', False), 

1593 ) 

1594 

1595 return data 

1596 

1597 def save(self): 

1598 """Actually perform the stock merging action. 

1599 

1600 At this point we are confident that the merge can take place 

1601 """ 

1602 data = self.validated_data 

1603 

1604 base_item = data['base_item'] 

1605 items = data['items'][1:] 

1606 

1607 request = self.context['request'] 

1608 user = getattr(request, 'user', None) 

1609 

1610 items = [] 

1611 

1612 for item in data['items'][1:]: 

1613 items.append(item['item']) 

1614 

1615 base_item.merge_stock_items( 

1616 items, 

1617 allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False), 

1618 allow_mismatched_status=data.get('allow_mismatched_status', False), 

1619 user=user, 

1620 location=data['location'], 

1621 notes=data.get('notes', None), 

1622 ) 

1623 

1624 

1625def stock_item_adjust_status_options(): 

1626 """Return a custom set of options for the StockItem status adjustment field. 

1627 

1628 In particular, include a Null option for the status field. 

1629 """ 

1630 return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)] 

1631 

1632 

1633class StockAdjustmentItemSerializer(serializers.Serializer): 

1634 """Serializer for a single StockItem within a stock adjustment request. 

1635 

1636 Required Fields: 

1637 - item: StockItem object 

1638 - quantity: Numerical quantity 

1639 

1640 Optional Fields (may be used by external tools) 

1641 - status: Change StockItem status code 

1642 - packaging: Change StockItem packaging 

1643 - batch: Change StockItem batch code 

1644 

1645 The optional fields can be used to adjust values for individual stock items 

1646 """ 

1647 

1648 class Meta: 

1649 """Metaclass options.""" 

1650 

1651 fields = ['pk', 'quantity', 'batch', 'status', 'packaging'] 

1652 

1653 def __init__(self, *args, **kwargs): 

1654 """Initialize the serializer.""" 

1655 # Store the 'require_in_stock' status 

1656 # Either True / False / None 

1657 self.require_in_stock = kwargs.pop('require_in_stock', True) 

1658 self.require_non_zero = kwargs.pop('require_non_zero', False) 

1659 

1660 super().__init__(*args, **kwargs) 

1661 

1662 pk = serializers.PrimaryKeyRelatedField( 

1663 queryset=StockItem.objects.all(), 

1664 many=False, 

1665 allow_null=False, 

1666 required=True, 

1667 label='stock_item', 

1668 help_text=_('StockItem primary key value'), 

1669 ) 

1670 

1671 def validate_pk(self, stock_item: StockItem) -> StockItem: 

1672 """Ensure the stock item is valid.""" 

1673 if self.require_in_stock == True: 

1674 allow_out_of_stock_transfer = get_global_setting( 

1675 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False 

1676 ) 

1677 

1678 if not allow_out_of_stock_transfer and not stock_item.is_in_stock( 

1679 check_status=False, check_quantity=False, check_in_production=False 

1680 ): 

1681 raise ValidationError(_('Stock item is not in stock')) 

1682 elif self.require_in_stock == False: 

1683 if stock_item.is_in_stock(): 

1684 raise ValidationError(_('Stock item is already in stock')) 

1685 

1686 return stock_item 

1687 

1688 quantity = serializers.DecimalField( 

1689 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True 

1690 ) 

1691 

1692 def validate_quantity(self, quantity): 

1693 """Validate the quantity value.""" 

1694 if self.require_non_zero and quantity <= 0: 1694 ↛ 1695line 1694 didn't jump to line 1695 because the condition on line 1694 was never true

1695 raise ValidationError(_('Quantity must be greater than zero')) 

1696 

1697 if quantity < 0: 1697 ↛ 1698line 1697 didn't jump to line 1698 because the condition on line 1697 was never true

1698 raise ValidationError(_('Quantity must not be negative')) 

1699 

1700 return quantity 

1701 

1702 batch = serializers.CharField( 

1703 max_length=100, 

1704 required=False, 

1705 allow_blank=True, 

1706 label=_('Batch Code'), 

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

1708 ) 

1709 

1710 status = StockStatusCustomSerializer( 

1711 choices=stock_item_adjust_status_options(), 

1712 default=None, 

1713 required=False, 

1714 allow_blank=True, 

1715 ) 

1716 

1717 packaging = serializers.CharField( 

1718 max_length=50, 

1719 required=False, 

1720 allow_blank=True, 

1721 label=_('Packaging'), 

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

1723 ) 

1724 

1725 

1726class StockAdjustmentSerializer(serializers.Serializer): 

1727 """Base class for managing stock adjustment actions via the API.""" 

1728 

1729 class Meta: 

1730 """Metaclass options.""" 

1731 

1732 fields = ['items', 'notes'] 

1733 

1734 items = StockAdjustmentItemSerializer(many=True) 

1735 

1736 notes = serializers.CharField( 

1737 required=False, 

1738 allow_blank=True, 

1739 label=_('Notes'), 

1740 help_text=_('Stock transaction notes'), 

1741 ) 

1742 

1743 def validate(self, data): 

1744 """Make sure items are provided.""" 

1745 super().validate(data) 

1746 

1747 items = data.get('items', []) 

1748 

1749 if len(items) == 0: 1749 ↛ 1752line 1749 didn't jump to line 1752 because the condition on line 1749 was always true

1750 raise ValidationError(_('A list of stock items must be provided')) 

1751 

1752 return data 

1753 

1754 

1755class StockCountSerializer(StockAdjustmentSerializer): 

1756 """Serializer for counting stock items.""" 

1757 

1758 class Meta: 

1759 """Metaclass options.""" 

1760 

1761 fields = ['items', 'notes', 'location'] 

1762 

1763 location = serializers.PrimaryKeyRelatedField( 

1764 queryset=StockLocation.objects.filter(structural=False), 

1765 many=False, 

1766 required=False, 

1767 allow_null=True, 

1768 label=_('Location'), 

1769 help_text=_('Set stock location for counted items (optional)'), 

1770 ) 

1771 

1772 def save(self): 

1773 """Count stock.""" 

1774 request = self.context['request'] 

1775 

1776 data = self.validated_data 

1777 items = data['items'] 

1778 notes = data.get('notes', '') 

1779 location = data.get('location', None) 

1780 

1781 with transaction.atomic(): 

1782 for item in items: 

1783 stock_item = item['pk'] 

1784 quantity = item['quantity'] 

1785 

1786 # Optional fields 

1787 extra = {} 

1788 

1789 for field_name in StockItem.optional_transfer_fields(): 

1790 if field_value := item.get(field_name, None): 

1791 extra[field_name] = field_value 

1792 

1793 if location is not None: 

1794 extra['location'] = location 

1795 

1796 stock_item.stocktake(quantity, request.user, notes=notes, **extra) 

1797 

1798 

1799class StockAddSerializer(StockAdjustmentSerializer): 

1800 """Serializer for adding stock to stock item(s).""" 

1801 

1802 def save(self): 

1803 """Add stock.""" 

1804 request = self.context['request'] 

1805 

1806 data = self.validated_data 

1807 notes = data.get('notes', '') 

1808 

1809 with transaction.atomic(): 

1810 for item in data['items']: 

1811 stock_item = item['pk'] 

1812 quantity = item['quantity'] 

1813 

1814 if quantity is None or quantity <= 0: 

1815 # Ignore in this case - no stock to add 

1816 continue 

1817 

1818 # Optional fields 

1819 extra = {} 

1820 

1821 for field_name in StockItem.optional_transfer_fields(): 

1822 if field_value := item.get(field_name, None): 

1823 extra[field_name] = field_value 

1824 

1825 stock_item.add_stock(quantity, request.user, notes=notes, **extra) 

1826 

1827 

1828class StockRemoveSerializer(StockAdjustmentSerializer): 

1829 """Serializer for removing stock from stock item(s).""" 

1830 

1831 def save(self): 

1832 """Remove stock.""" 

1833 request = self.context['request'] 

1834 

1835 data = self.validated_data 

1836 notes = data.get('notes', '') 

1837 

1838 with transaction.atomic(): 

1839 for item in data['items']: 

1840 stock_item = item['pk'] 

1841 quantity = item['quantity'] 

1842 

1843 # Ignore in this case - no stock to remove 

1844 if quantity is None or quantity <= 0: 

1845 continue 

1846 

1847 # Optional fields 

1848 extra = {} 

1849 

1850 for field_name in StockItem.optional_transfer_fields(): 

1851 if field_value := item.get(field_name, None): 

1852 extra[field_name] = field_value 

1853 

1854 stock_item.take_stock(quantity, request.user, notes=notes, **extra) 

1855 

1856 

1857class StockTransferSerializer(StockAdjustmentSerializer): 

1858 """Serializer for transferring (moving) stock item(s).""" 

1859 

1860 class Meta: 

1861 """Metaclass options.""" 

1862 

1863 fields = ['items', 'notes', 'location'] 

1864 

1865 items = StockAdjustmentItemSerializer(many=True, require_non_zero=True) 

1866 

1867 location = serializers.PrimaryKeyRelatedField( 

1868 queryset=StockLocation.objects.filter(structural=False), 

1869 many=False, 

1870 required=True, 

1871 allow_null=False, 

1872 label=_('Location'), 

1873 help_text=_('Destination stock location'), 

1874 ) 

1875 

1876 def save(self): 

1877 """Transfer stock.""" 

1878 request = self.context['request'] 

1879 

1880 data = self.validated_data 

1881 

1882 items = data['items'] 

1883 notes = data.get('notes', '') 

1884 location = data['location'] 

1885 

1886 with transaction.atomic(): 

1887 for item in items: 

1888 # Required fields 

1889 stock_item = item['pk'] 

1890 quantity = item['quantity'] 

1891 

1892 # Optional fields 

1893 kwargs = {} 

1894 

1895 for field_name in StockItem.optional_transfer_fields(): 

1896 if field_value := item.get(field_name, None): 

1897 kwargs[field_name] = field_value 

1898 

1899 stock_item.move( 

1900 location, notes, request.user, quantity=quantity, **kwargs 

1901 ) 

1902 

1903 

1904class StockReturnSerializer(StockAdjustmentSerializer): 

1905 """Serializer class for returning stock item(s) into stock.""" 

1906 

1907 class Meta: 

1908 """Metaclass options.""" 

1909 

1910 fields = ['items', 'notes', 'location'] 

1911 

1912 items = StockAdjustmentItemSerializer( 

1913 many=True, require_in_stock=False, require_non_zero=True 

1914 ) 

1915 

1916 location = serializers.PrimaryKeyRelatedField( 

1917 queryset=StockLocation.objects.filter(structural=False), 

1918 many=False, 

1919 required=True, 

1920 allow_null=False, 

1921 label=_('Location'), 

1922 help_text=_('Destination stock location'), 

1923 ) 

1924 

1925 merge = serializers.BooleanField( 

1926 default=False, 

1927 required=False, 

1928 label=_('Merge into existing stock'), 

1929 help_text=_('Merge returned items into existing stock items if possible'), 

1930 ) 

1931 

1932 def save(self): 

1933 """Return the provided items into stock.""" 

1934 request = self.context['request'] 

1935 data = self.validated_data 

1936 items = data['items'] 

1937 merge = data.get('merge', False) 

1938 notes = data.get('notes', '') 

1939 location = data['location'] 

1940 

1941 with transaction.atomic(): 

1942 for item in items: 

1943 stock_item = item['pk'] 

1944 quantity = item.get('quantity', None) 

1945 

1946 # Optional fields 

1947 kwargs = {'notes': notes} 

1948 

1949 for field_name in StockItem.optional_transfer_fields(): 

1950 if field_value := item.get(field_name, None): 

1951 kwargs[field_name] = field_value 

1952 

1953 stock_item.return_to_stock( 

1954 location, 

1955 quantity=quantity, 

1956 merge=merge, 

1957 user=request.user, 

1958 **kwargs, 

1959 ) 

1960 

1961 

1962class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer): 

1963 """Serializer for extra serial number information about a stock item.""" 

1964 

1965 class Meta: 

1966 """Metaclass options.""" 

1967 

1968 model = StockItem 

1969 fields = ['next', 'previous'] 

1970 

1971 next = StockItemSerializer( 

1972 read_only=True, source='get_next_stock_item', label=_('Next Serial Number') 

1973 ) 

1974 

1975 previous = StockItemSerializer( 

1976 read_only=True, 

1977 source='get_previous_stock_item', 

1978 label=_('Previous Serial Number'), 

1979 )