Coverage for src/backend/InvenTree/build/serializers.py: 49%

478 statements  

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

1"""JSON serializers for Build API.""" 

2 

3from decimal import Decimal 

4 

5from django.core.exceptions import ValidationError as DjangoValidationError 

6from django.db import models, transaction 

7from django.db.models import ( 

8 BooleanField, 

9 Case, 

10 ExpressionWrapper, 

11 F, 

12 FloatField, 

13 Q, 

14 Sum, 

15 Value, 

16 When, 

17) 

18from django.db.models.functions import Coalesce, Greatest 

19from django.utils.translation import gettext_lazy as _ 

20 

21from rest_framework import serializers 

22from rest_framework.serializers import ValidationError 

23 

24import common.filters 

25import common.settings 

26import company.serializers 

27import InvenTree.helpers 

28import part.filters 

29import part.serializers as part_serializers 

30import stock.models as stock_models 

31from common.settings import get_global_setting 

32from generic.states.fields import InvenTreeCustomStatusSerializerMixin 

33from InvenTree.mixins import DataImportExportSerializerMixin 

34from InvenTree.serializers import ( 

35 CustomStatusSerializerMixin, 

36 FilterableSerializerMixin, 

37 InvenTreeDecimalField, 

38 InvenTreeModelSerializer, 

39 InvenTreeTaggitSerializer, 

40 NotesFieldMixin, 

41 OptionalField, 

42) 

43from stock.generators import generate_batch_code 

44from stock.models import StockItem, StockLocation 

45from stock.serializers import ( 

46 LocationBriefSerializer, 

47 StockItemSerializer, 

48 StockStatusCustomSerializer, 

49) 

50from stock.status_codes import StockStatus 

51from users.serializers import OwnerSerializer, UserSerializer 

52 

53from .models import Build, BuildItem, BuildLine 

54from .status_codes import BuildStatus 

55 

56 

57class BuildSerializer( 

58 CustomStatusSerializerMixin, 

59 FilterableSerializerMixin, 

60 NotesFieldMixin, 

61 InvenTreeTaggitSerializer, 

62 DataImportExportSerializerMixin, 

63 InvenTreeCustomStatusSerializerMixin, 

64 InvenTreeModelSerializer, 

65): 

66 """Serializes a Build object.""" 

67 

68 class Meta: 

69 """Serializer metaclass.""" 

70 

71 model = Build 

72 fields = [ 

73 'pk', 

74 'title', 

75 'barcode_hash', 

76 'batch', 

77 'creation_date', 

78 'completed', 

79 'completion_date', 

80 'destination', 

81 'external', 

82 'parent', 

83 'part', 

84 'part_name', 

85 'part_detail', 

86 'project_code', 

87 'project_code_label', 

88 'project_code_detail', 

89 'overdue', 

90 'reference', 

91 'sales_order', 

92 'quantity', 

93 'start_date', 

94 'status', 

95 'status_text', 

96 'status_custom_key', 

97 'target_date', 

98 'take_from', 

99 'notes', 

100 'link', 

101 'issued_by', 

102 'issued_by_detail', 

103 'responsible', 

104 'responsible_detail', 

105 'parameters', 

106 'priority', 

107 'level', 

108 'tags', 

109 ] 

110 read_only_fields = [ 

111 'completed', 

112 'creation_date', 

113 'issued_by', 

114 'completion_date', 

115 'status', 

116 'status_text', 

117 'level', 

118 ] 

119 

120 reference = serializers.CharField(required=True) 

121 

122 level = serializers.IntegerField(label=_('Build Level'), read_only=True) 

123 

124 part_detail = OptionalField( 

125 serializer_class=part_serializers.PartBriefSerializer, 

126 serializer_kwargs={'source': 'part', 'many': False, 'read_only': True}, 

127 default_include=True, 

128 prefetch_fields=['part', 'part__category', 'part__pricing_data'], 

129 ) 

130 

131 parameters = common.filters.enable_parameters_filter() 

132 

133 tags = common.filters.enable_tags_filter() 

134 

135 part_name = serializers.CharField( 

136 source='part.name', read_only=True, label=_('Part Name') 

137 ) 

138 

139 quantity = InvenTreeDecimalField() 

140 

141 overdue = serializers.BooleanField(read_only=True, default=False) 

142 

143 issued_by_detail = OptionalField( 

144 serializer_class=UserSerializer, 

145 serializer_kwargs={'source': 'issued_by', 'read_only': True}, 

146 default_include=True, 

147 filter_name='user_detail', 

148 prefetch_fields=['issued_by'], 

149 ) 

150 

151 responsible_detail = OptionalField( 

152 serializer_class=OwnerSerializer, 

153 serializer_kwargs={ 

154 'source': 'responsible', 

155 'read_only': True, 

156 'allow_null': True, 

157 }, 

158 default_include=True, 

159 filter_name='user_detail', 

160 prefetch_fields=['responsible'], 

161 ) 

162 

163 barcode_hash = serializers.CharField(read_only=True) 

164 

165 project_code_label = common.filters.enable_project_label_filter() 

166 

167 project_code_detail = common.filters.enable_project_code_filter() 

168 

169 @staticmethod 

170 def annotate_queryset(queryset): 

171 """Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible. 

172 

173 The following annotated fields are added: 

174 

175 - overdue: True if the build is outstanding *and* the completion date has past 

176 """ 

177 # Annotate a boolean 'overdue' flag 

178 

179 queryset = queryset.annotate( 

180 overdue=Case( 

181 When( 

182 Build.get_overdue_filter(), 

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

184 ), 

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

186 ) 

187 ) 

188 

189 return queryset 

190 

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

192 """Determine if extra serializer fields are required.""" 

193 kwargs.pop('create', False) 

194 

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

196 

197 def validate_reference(self, reference): 

198 """Custom validation for the Build reference field.""" 

199 # Ensure the reference matches the required pattern 

200 Build.validate_reference_field(reference) 

201 

202 return reference 

203 

204 

205class BuildOutputSerializer(serializers.Serializer): 

206 """Serializer for a "BuildOutput". 

207 

208 Note that a "BuildOutput" is really just a StockItem which is "in production"! 

209 """ 

210 

211 class Meta: 

212 """Serializer metaclass.""" 

213 

214 fields = ['output'] 

215 

216 output = serializers.PrimaryKeyRelatedField( 

217 queryset=StockItem.objects.all(), 

218 many=False, 

219 allow_null=False, 

220 required=True, 

221 label=_('Build Output'), 

222 ) 

223 

224 def validate_output(self, output): 

225 """Perform validation for the output (StockItem) provided to the serializer.""" 

226 build = self.context['build'] 

227 

228 # As this serializer can be used in multiple contexts, we need to work out why we are here 

229 to_complete = self.context.get('to_complete', False) 

230 

231 # The stock item must point to the build 

232 if output.build != build: 

233 raise ValidationError(_('Build output does not match the parent build')) 

234 

235 # The part must match! 

236 if output.part != build.part: 

237 raise ValidationError(_('Output part does not match BuildOrder part')) 

238 

239 # The build output must be "in production" 

240 if not output.is_building: 

241 raise ValidationError(_('This build output has already been completed')) 

242 

243 if to_complete: 

244 # The build output must have all tracked parts allocated 

245 if not build.is_output_fully_allocated(output): 

246 # Check if the user has specified that incomplete allocations are ok 

247 if request := self.context.get('request'): 

248 accept_incomplete = InvenTree.helpers.str2bool( 

249 request.data.get('accept_incomplete_allocation', False) 

250 ) 

251 else: 

252 accept_incomplete = False 

253 

254 if not accept_incomplete: 

255 raise ValidationError(_('This build output is not fully allocated')) 

256 

257 return output 

258 

259 

260class BuildOutputQuantitySerializer(BuildOutputSerializer): 

261 """Build output with quantity field.""" 

262 

263 class Meta: 

264 """Serializer metaclass.""" 

265 

266 fields = [*BuildOutputSerializer.Meta.fields, 'quantity'] 

267 

268 quantity = serializers.DecimalField( 

269 max_digits=15, 

270 decimal_places=5, 

271 min_value=Decimal(0), 

272 required=False, 

273 label=_('Quantity'), 

274 help_text=_('Enter quantity for build output'), 

275 ) 

276 

277 def validate(self, data): 

278 """Validate the serializer data.""" 

279 data = super().validate(data) 

280 

281 output = data.get('output') 

282 quantity = data.get('quantity') 

283 

284 if quantity is not None: 

285 if quantity <= 0: 

286 raise ValidationError({ 

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

288 }) 

289 

290 if quantity > output.quantity: 

291 raise ValidationError({ 

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

293 }) 

294 

295 return data 

296 

297 

298class BuildOutputCreateSerializer(serializers.Serializer): 

299 """Serializer for creating a new BuildOutput against a BuildOrder. 

300 

301 URL pattern is "/api/build/<pk>/create-output/", where <pk> is the PK of a Build. 

302 

303 The Build object is provided to the serializer context. 

304 """ 

305 

306 class Meta: 

307 """Serializer metaclass.""" 

308 

309 fields = [ 

310 'quantity', 

311 'batch_code', 

312 'serial_numbers', 

313 'location', 

314 'auto_allocate', 

315 ] 

316 

317 quantity = serializers.DecimalField( 

318 max_digits=15, 

319 decimal_places=5, 

320 min_value=Decimal(0), 

321 required=True, 

322 label=_('Quantity'), 

323 help_text=_('Enter quantity for build output'), 

324 ) 

325 

326 def get_build(self): 

327 """Return the Build instance associated with this serializer.""" 

328 return self.context['build'] 

329 

330 def get_part(self): 

331 """Return the Part instance associated with the build.""" 

332 return self.get_build().part 

333 

334 def validate_quantity(self, quantity): 

335 """Validate the provided quantity field.""" 

336 if quantity <= 0: 336 ↛ 339line 336 didn't jump to line 339 because the condition on line 336 was always true

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

338 

339 part = self.get_part() 

340 

341 if int(quantity) != quantity: 

342 # Quantity must be an integer value if the part being built is trackable 

343 if part.trackable: 

344 raise ValidationError( 

345 _('Integer quantity required for trackable parts') 

346 ) 

347 

348 if part.has_trackable_parts: 

349 raise ValidationError( 

350 _( 

351 'Integer quantity required, as the bill of materials contains trackable parts' 

352 ) 

353 ) 

354 

355 return quantity 

356 

357 batch_code = serializers.CharField( 

358 required=False, 

359 allow_blank=True, 

360 default=generate_batch_code, 

361 label=_('Batch Code'), 

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

363 ) 

364 

365 serial_numbers = serializers.CharField( 

366 allow_blank=True, 

367 required=False, 

368 label=_('Serial Numbers'), 

369 help_text=_('Enter serial numbers for build outputs'), 

370 ) 

371 

372 location = serializers.PrimaryKeyRelatedField( 

373 queryset=StockLocation.objects.all(), 

374 label=_('Location'), 

375 help_text=_('Stock location for build output'), 

376 required=False, 

377 allow_null=True, 

378 ) 

379 

380 def validate_serial_numbers(self, serial_numbers): 

381 """Clean the provided serial number string.""" 

382 serial_numbers = serial_numbers.strip() 

383 

384 return serial_numbers 

385 

386 auto_allocate = serializers.BooleanField( 

387 required=False, 

388 default=False, 

389 allow_null=True, 

390 label=_('Auto Allocate Serial Numbers'), 

391 help_text=_( 

392 'Automatically allocate required items with matching serial numbers' 

393 ), 

394 ) 

395 

396 def validate(self, data): 

397 """Perform form validation.""" 

398 part = self.get_part() 

399 

400 # Cache a list of serial numbers (to be used in the "save" method) 

401 self.serials = None 

402 

403 quantity = data['quantity'] 

404 serial_numbers = data.get('serial_numbers', '') 

405 

406 if part.trackable and not serial_numbers: 

407 raise ValidationError({ 

408 'serial_numbers': _( 

409 'Serial numbers must be provided for trackable parts' 

410 ) 

411 }) 

412 

413 if serial_numbers: 

414 try: 

415 self.serials = InvenTree.helpers.extract_serial_numbers( 

416 serial_numbers, quantity, part.get_latest_serial_number(), part=part 

417 ) 

418 except DjangoValidationError as e: 

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

420 

421 # Check for conflicting serial numbers 

422 existing = part.find_conflicting_serial_numbers(self.serials) 

423 

424 if len(existing) > 0: 

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

426 msg += ' : ' 

427 msg += ','.join([str(e) for e in existing]) 

428 

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

430 

431 return data 

432 

433 def save(self): 

434 """Generate the new build output(s).""" 

435 data = self.validated_data 

436 

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

438 build = self.get_build() 

439 

440 return build.create_build_output( 

441 data['quantity'], 

442 serials=self.serials, 

443 batch=data.get('batch_code', ''), 

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

445 auto_allocate=data.get('auto_allocate', False), 

446 user=request.user if request else None, 

447 ) 

448 

449 

450class BuildOutputDeleteSerializer(serializers.Serializer): 

451 """DRF serializer for deleting (cancelling) one or more build outputs.""" 

452 

453 class Meta: 

454 """Serializer metaclass.""" 

455 

456 fields = ['outputs'] 

457 

458 outputs = BuildOutputSerializer(many=True, required=True) 

459 

460 def validate(self, data): 

461 """Perform data validation for this serializer.""" 

462 data = super().validate(data) 

463 

464 outputs = data.get('outputs', []) 

465 

466 if len(outputs) == 0: 

467 raise ValidationError(_('A list of build outputs must be provided')) 

468 

469 return data 

470 

471 

472class BuildOutputScrapSerializer(serializers.Serializer): 

473 """Scrapping one or more build outputs.""" 

474 

475 class Meta: 

476 """Serializer metaclass.""" 

477 

478 fields = ['outputs', 'location', 'notes'] 

479 

480 outputs = BuildOutputQuantitySerializer(many=True, required=True) 

481 

482 location = serializers.PrimaryKeyRelatedField( 

483 queryset=StockLocation.objects.all(), 

484 many=False, 

485 allow_null=False, 

486 required=True, 

487 label=_('Location'), 

488 help_text=_('Stock location for scrapped outputs'), 

489 ) 

490 

491 discard_allocations = serializers.BooleanField( 

492 required=False, 

493 default=False, 

494 label=_('Discard Allocations'), 

495 help_text=_('Discard any stock allocations for scrapped outputs'), 

496 ) 

497 

498 notes = serializers.CharField( 

499 label=_('Notes'), 

500 help_text=_('Reason for scrapping build output(s)'), 

501 required=True, 

502 allow_blank=False, 

503 ) 

504 

505 def validate(self, data): 

506 """Perform validation on the serializer data.""" 

507 super().validate(data) 

508 outputs = data.get('outputs', []) 

509 

510 if len(outputs) == 0: 

511 raise ValidationError(_('A list of build outputs must be provided')) 

512 

513 return data 

514 

515 

516class BuildOutputCompleteSerializer(serializers.Serializer): 

517 """DRF serializer for completing one or more build outputs.""" 

518 

519 class Meta: 

520 """Serializer metaclass.""" 

521 

522 fields = [ 

523 'outputs', 

524 'location', 

525 'status_custom_key', 

526 'accept_incomplete_allocation', 

527 'notes', 

528 ] 

529 

530 outputs = BuildOutputQuantitySerializer(many=True, required=True) 

531 

532 location = serializers.PrimaryKeyRelatedField( 

533 queryset=StockLocation.objects.all(), 

534 required=True, 

535 many=False, 

536 label=_('Location'), 

537 help_text=_('Location for completed build outputs'), 

538 ) 

539 

540 status_custom_key = StockStatusCustomSerializer(default=StockStatus.OK.value) 

541 

542 accept_incomplete_allocation = serializers.BooleanField( 

543 default=False, 

544 required=False, 

545 label=_('Accept Incomplete Allocation'), 

546 help_text=_('Complete outputs if stock has not been fully allocated'), 

547 ) 

548 

549 notes = serializers.CharField(label=_('Notes'), required=False, allow_blank=True) 

550 

551 def validate(self, data): 

552 """Perform data validation for this serializer.""" 

553 super().validate(data) 

554 

555 outputs = data.get('outputs', []) 

556 

557 if common.settings.prevent_build_output_complete_on_incompleted_tests(): 

558 errors = [] 

559 for output in outputs: 

560 stock_item = output['output'] 

561 if ( 

562 stock_item.hasRequiredTests() 

563 and not stock_item.passedAllRequiredTests() 

564 ): 

565 serial = stock_item.serial 

566 

567 if serial: 

568 errors.append( 

569 _( 

570 f'Build output {serial} has not passed all required tests' 

571 ) 

572 ) 

573 else: 

574 errors.append( 

575 _('Build output has not passed all required tests') 

576 ) 

577 

578 if errors: 

579 raise ValidationError(errors) 

580 

581 if len(outputs) == 0: 

582 raise ValidationError(_('A list of build outputs must be provided')) 

583 

584 return data 

585 

586 

587class BuildIssueSerializer(serializers.Serializer): 

588 """DRF serializer for issuing a build order.""" 

589 

590 class Meta: 

591 """Serializer metaclass.""" 

592 

593 fields = [] 

594 

595 def save(self): 

596 """Issue the specified build order.""" 

597 build = self.context['build'] 

598 build.issue_build() 

599 

600 

601class BuildHoldSerializer(serializers.Serializer): 

602 """DRF serializer for placing a BuildOrder on hold.""" 

603 

604 class Meta: 

605 """Serializer metaclass.""" 

606 

607 fields = [] 

608 

609 def save(self): 

610 """Place the specified build on hold.""" 

611 build = self.context['build'] 

612 

613 build.hold_build() 

614 

615 

616class BuildCancelSerializer(serializers.Serializer): 

617 """Cancel an active BuildOrder.""" 

618 

619 class Meta: 

620 """Serializer metaclass.""" 

621 

622 fields = ['remove_allocated_stock', 'remove_incomplete_outputs'] 

623 

624 def get_context_data(self): 

625 """Retrieve extra context data from this serializer.""" 

626 build = self.context['build'] 

627 

628 return { 

629 'has_allocated_stock': build.is_partially_allocated(), 

630 'incomplete_outputs': build.incomplete_count, 

631 'completed_outputs': build.complete_count, 

632 } 

633 

634 remove_allocated_stock = serializers.BooleanField( 

635 label=_('Consume Allocated Stock'), 

636 help_text=_('Consume any stock which has already been allocated to this build'), 

637 required=False, 

638 default=False, 

639 ) 

640 

641 remove_incomplete_outputs = serializers.BooleanField( 

642 label=_('Remove Incomplete Outputs'), 

643 help_text=_('Delete any build outputs which have not been completed'), 

644 required=False, 

645 default=False, 

646 ) 

647 

648 def save(self): 

649 """Cancel the specified build.""" 

650 build = self.context['build'] 

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

652 

653 data = self.validated_data 

654 

655 build.cancel_build( 

656 request.user if request else None, 

657 remove_allocated_stock=data.get('remove_allocated_stock', False), 

658 remove_incomplete_outputs=data.get('remove_incomplete_outputs', False), 

659 ) 

660 

661 

662class OverallocationChoice: 

663 """Utility class to contain options for handling over allocated stock items.""" 

664 

665 REJECT = 'reject' 

666 ACCEPT = 'accept' 

667 TRIM = 'trim' 

668 

669 OPTIONS = { 

670 REJECT: _('Not permitted'), 

671 ACCEPT: _('Accept as consumed by this build order'), 

672 TRIM: _('Deallocate before completing this build order'), 

673 } 

674 

675 

676class BuildCompleteSerializer(serializers.Serializer): 

677 """DRF serializer for marking a BuildOrder as complete.""" 

678 

679 class Meta: 

680 """Serializer metaclass.""" 

681 

682 fields = ['accept_overallocated', 'accept_unallocated', 'accept_incomplete'] 

683 

684 def get_context_data(self): 

685 """Retrieve extra context data for this serializer. 

686 

687 This is so we can determine (at run time) whether the build is ready to be completed. 

688 """ 

689 build = self.context['build'] 

690 

691 return { 

692 'overallocated': build.is_overallocated(), 

693 'allocated': build.are_untracked_parts_allocated, 

694 'remaining': build.remaining, 

695 'incomplete': build.incomplete_count, 

696 } 

697 

698 accept_overallocated = serializers.ChoiceField( 

699 label=_('Overallocated Stock'), 

700 choices=list(OverallocationChoice.OPTIONS.items()), 

701 help_text=_( 

702 'How do you want to handle extra stock items assigned to the build order' 

703 ), 

704 required=False, 

705 default=OverallocationChoice.REJECT, 

706 ) 

707 

708 def validate_accept_overallocated(self, value): 

709 """Check if the 'accept_overallocated' field is required.""" 

710 build = self.context['build'] 

711 

712 if build.is_overallocated() and value == OverallocationChoice.REJECT: 

713 raise ValidationError(_('Some stock items have been overallocated')) 

714 

715 return value 

716 

717 accept_unallocated = serializers.BooleanField( 

718 label=_('Accept Unallocated'), 

719 help_text=_( 

720 'Accept that stock items have not been fully allocated to this build order' 

721 ), 

722 required=False, 

723 default=False, 

724 ) 

725 

726 def validate_accept_unallocated(self, value): 

727 """Check if the 'accept_unallocated' field is required.""" 

728 build = self.context['build'] 

729 

730 if not build.are_untracked_parts_allocated and not value: 

731 raise ValidationError(_('Required stock has not been fully allocated')) 

732 

733 return value 

734 

735 accept_incomplete = serializers.BooleanField( 

736 label=_('Accept Incomplete'), 

737 help_text=_( 

738 'Accept that the required number of build outputs have not been completed' 

739 ), 

740 required=False, 

741 default=False, 

742 ) 

743 

744 def validate_accept_incomplete(self, value): 

745 """Check if the 'accept_incomplete' field is required.""" 

746 build = self.context['build'] 

747 

748 if build.remaining > 0 and not value: 

749 raise ValidationError(_('Required build quantity has not been completed')) 

750 

751 return value 

752 

753 def validate(self, data): 

754 """Perform validation of this serializer prior to saving.""" 

755 build = self.context['build'] 

756 

757 if ( 

758 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') 

759 and build.has_open_child_builds 

760 ): 

761 raise ValidationError(_('Build order has open child build orders')) 

762 

763 if build.status != BuildStatus.PRODUCTION.value: 

764 raise ValidationError(_('Build order must be in production state')) 

765 

766 if build.incomplete_count > 0: 

767 raise ValidationError(_('Build order has incomplete outputs')) 

768 

769 return data 

770 

771 def save(self): 

772 """Complete the specified build output.""" 

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

774 build = self.context['build'] 

775 

776 data = self.validated_data 

777 

778 build.complete_build( 

779 request.user if request else None, 

780 trim_allocated_stock=data.get( 

781 'accept_overallocated', OverallocationChoice.REJECT 

782 ) 

783 == OverallocationChoice.TRIM, 

784 ) 

785 

786 

787class BuildUnallocationSerializer(serializers.Serializer): 

788 """DRF serializer for unallocating stock from a BuildOrder. 

789 

790 Allocated stock can be unallocated with a number of filters: 

791 

792 - output: Filter against a particular build output (blank = untracked stock) 

793 - bom_item: Filter against a particular BOM line item 

794 """ 

795 

796 class Meta: 

797 """Serializer metaclass.""" 

798 

799 fields = ['build_line', 'output'] 

800 

801 build_line = serializers.PrimaryKeyRelatedField( 

802 queryset=BuildLine.objects.all(), 

803 many=False, 

804 allow_null=True, 

805 required=False, 

806 label=_('Build Line'), 

807 ) 

808 

809 output = serializers.PrimaryKeyRelatedField( 

810 queryset=StockItem.objects.filter(is_building=True), 

811 many=False, 

812 allow_null=True, 

813 required=False, 

814 label=_('Build output'), 

815 ) 

816 

817 def validate_output(self, stock_item): 

818 """Validation for the output StockItem instance. Stock item must point to the same build order!""" 

819 build = self.context['build'] 

820 

821 if stock_item and stock_item.build != build: 

822 raise ValidationError(_('Build output must point to the same build')) 

823 

824 return stock_item 

825 

826 def save(self): 

827 """Save the serializer data. 

828 

829 This performs the actual unallocation against the build order 

830 """ 

831 build = self.context['build'] 

832 

833 data = self.validated_data 

834 

835 build.deallocate_stock( 

836 build_line=data.get('build_line', None), output=data.get('output', None) 

837 ) 

838 

839 

840class BuildAllocationItemSerializer(serializers.Serializer): 

841 """A serializer for allocating a single stock item against a build order.""" 

842 

843 class Meta: 

844 """Serializer metaclass.""" 

845 

846 fields = ['build_item', 'stock_item', 'quantity', 'output'] 

847 

848 build_line = serializers.PrimaryKeyRelatedField( 

849 queryset=BuildLine.objects.all(), 

850 many=False, 

851 allow_null=False, 

852 required=True, 

853 label=_('Build Line Item'), 

854 ) 

855 

856 def validate_build_line(self, build_line): 

857 """Check if the parts match.""" 

858 build = self.context['build'] 

859 

860 # BomItem should point to the same 'part' as the parent build 

861 if build.part != build_line.bom_item.part: 

862 # If not, it may be marked as "inherited" from a parent part 

863 if ( 

864 build_line.bom_item.inherited 

865 and build.part 

866 in build_line.bom_item.part.get_descendants(include_self=False) 

867 ): 

868 pass 

869 else: 

870 raise ValidationError( 

871 _('bom_item.part must point to the same part as the build order') 

872 ) 

873 

874 return build_line 

875 

876 stock_item = serializers.PrimaryKeyRelatedField( 

877 queryset=StockItem.objects.all(), 

878 many=False, 

879 allow_null=False, 

880 required=True, 

881 label=_('Stock Item'), 

882 ) 

883 

884 def validate_stock_item(self, stock_item): 

885 """Perform validation of the stock_item field.""" 

886 if not stock_item.in_stock: 

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

888 

889 return stock_item 

890 

891 quantity = serializers.DecimalField( 

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

893 ) 

894 

895 def validate_quantity(self, quantity): 

896 """Perform validation of the 'quantity' field.""" 

897 if quantity <= 0: 

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

899 

900 return quantity 

901 

902 output = serializers.PrimaryKeyRelatedField( 

903 queryset=StockItem.objects.filter(is_building=True), 

904 many=False, 

905 allow_null=True, 

906 required=False, 

907 label=_('Build Output'), 

908 ) 

909 

910 def validate(self, data): 

911 """Perform data validation for this item.""" 

912 super().validate(data) 

913 

914 build_line = data['build_line'] 

915 stock_item = data['stock_item'] 

916 quantity = data['quantity'] 

917 output = data.get('output', None) 

918 

919 # build = self.context['build'] 

920 

921 # TODO: Check that the "stock item" is valid for the referenced "sub_part" 

922 # Note: Because of allow_variants options, it may not be a direct match! 

923 

924 # Check that the quantity does not exceed the available amount from the stock item 

925 q = stock_item.unallocated_quantity() 

926 

927 if quantity > q: 

928 q = InvenTree.helpers.clean_decimal(q) 

929 

930 raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')}) 

931 

932 # Output *must* be set for trackable parts 

933 if output is None and build_line.bom_item.sub_part.trackable: 

934 raise ValidationError({ 

935 'output': _( 

936 'Build output must be specified for allocation of tracked parts' 

937 ) 

938 }) 

939 

940 # Output *cannot* be set for un-tracked parts 

941 if output is not None and not build_line.bom_item.sub_part.trackable: 

942 raise ValidationError({ 

943 'output': _( 

944 'Build output cannot be specified for allocation of untracked parts' 

945 ) 

946 }) 

947 

948 return data 

949 

950 

951class BuildAllocationSerializer(serializers.Serializer): 

952 """Serializer for allocating stock items against a build order.""" 

953 

954 class Meta: 

955 """Serializer metaclass.""" 

956 

957 fields = ['items'] 

958 

959 items = BuildAllocationItemSerializer(many=True) 

960 

961 def validate(self, data): 

962 """Validation.""" 

963 data = super().validate(data) 

964 

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

966 

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

968 raise ValidationError(_('Allocation items must be provided')) 

969 

970 return data 

971 

972 def save(self): 

973 """Perform the allocation.""" 

974 data = self.validated_data 

975 

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

977 

978 with transaction.atomic(): 

979 for item in items: 

980 build_line = item['build_line'] 

981 stock_item = item['stock_item'] 

982 quantity = item['quantity'] 

983 output = item.get('output', None) 

984 

985 # Ignore allocation for consumable BOM items 

986 if build_line.bom_item.consumable: 

987 continue 

988 

989 params = { 

990 'build_line': build_line, 

991 'stock_item': stock_item, 

992 'install_into': output, 

993 } 

994 

995 try: 

996 # Lock the row, so concurrent allocations cannot both read 

997 # the same starting quantity (lost update) 

998 if build_item := ( 

999 BuildItem.objects.select_for_update().filter(**params).first() 

1000 ): 

1001 # Find an existing BuildItem for this stock item 

1002 # If it exists, increase the quantity 

1003 build_item.quantity += quantity 

1004 build_item.save() 

1005 else: 

1006 # Create a new BuildItem to allocate stock 

1007 build_item = BuildItem.objects.create( 

1008 quantity=quantity, **params 

1009 ) 

1010 except (ValidationError, DjangoValidationError) as exc: 

1011 # Catch model errors and re-throw as DRF errors 

1012 raise ValidationError(detail=serializers.as_serializer_error(exc)) 

1013 

1014 

1015class BuildAutoAllocationSerializer(serializers.Serializer): 

1016 """DRF serializer for auto allocating stock items against a build order.""" 

1017 

1018 class Meta: 

1019 """Serializer metaclass.""" 

1020 

1021 fields = [ 

1022 'location', 

1023 'exclude_location', 

1024 'interchangeable', 

1025 'substitutes', 

1026 'optional_items', 

1027 ] 

1028 

1029 location = serializers.PrimaryKeyRelatedField( 

1030 queryset=StockLocation.objects.all(), 

1031 many=False, 

1032 allow_null=True, 

1033 required=False, 

1034 label=_('Source Location'), 

1035 help_text=_( 

1036 'Stock location where parts are to be sourced (leave blank to take from any location)' 

1037 ), 

1038 ) 

1039 

1040 exclude_location = serializers.PrimaryKeyRelatedField( 

1041 queryset=StockLocation.objects.all(), 

1042 many=False, 

1043 allow_null=True, 

1044 required=False, 

1045 label=_('Exclude Location'), 

1046 help_text=_('Exclude stock items from this selected location'), 

1047 ) 

1048 

1049 interchangeable = serializers.BooleanField( 

1050 default=False, 

1051 label=_('Interchangeable Stock'), 

1052 help_text=_('Stock items in multiple locations can be used interchangeably'), 

1053 ) 

1054 

1055 substitutes = serializers.BooleanField( 

1056 default=True, 

1057 label=_('Substitute Stock'), 

1058 help_text=_('Allow allocation of substitute parts'), 

1059 ) 

1060 

1061 optional_items = serializers.BooleanField( 

1062 default=False, 

1063 label=_('Optional Items'), 

1064 help_text=_('Allocate optional BOM items to build order'), 

1065 ) 

1066 

1067 item_type = serializers.ChoiceField( 

1068 default=Build.BuildItemTypes.UNTRACKED, 

1069 choices=[ 

1070 (Build.BuildItemTypes.ALL, _('All Items')), 

1071 (Build.BuildItemTypes.UNTRACKED, _('Untracked Items')), 

1072 (Build.BuildItemTypes.TRACKED, _('Tracked Items')), 

1073 ], 

1074 label=_('Item Type'), 

1075 help_text=_('Select item type to auto-allocate'), 

1076 ) 

1077 

1078 stock_sort_by = serializers.ChoiceField( 

1079 default=stock_models.STOCK_SORT_DEFAULT, 

1080 choices=stock_models.STOCK_SORT_CHOICES, 

1081 label=_('Stock Priority'), 

1082 help_text=_('Preferred order in which matching stock items are consumed'), 

1083 ) 

1084 

1085 build_lines = serializers.PrimaryKeyRelatedField( 

1086 queryset=BuildLine.objects.all(), 

1087 many=True, 

1088 required=False, 

1089 default=list, 

1090 label=_('Build Lines'), 

1091 help_text=_( 

1092 'Limit allocation to these build lines (leave blank to allocate all lines)' 

1093 ), 

1094 ) 

1095 

1096 

1097class BuildItemSerializer( 

1098 FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer 

1099): 

1100 """Serializes a BuildItem object, which is an allocation of a stock item against a build order.""" 

1101 

1102 export_child_fields = [ 

1103 'build_detail.reference', 

1104 'location_detail.name', 

1105 'part_detail.name', 

1106 'part_detail.description', 

1107 'part_detail.IPN', 

1108 'stock_item_detail.batch', 

1109 'stock_item_detail.packaging', 

1110 'stock_item_detail.part', 

1111 'stock_item_detail.quantity', 

1112 'stock_item_detail.serial', 

1113 'supplier_part_detail.SKU', 

1114 'supplier_part_detail.MPN', 

1115 ] 

1116 

1117 # These fields are only used for data export 

1118 export_only_fields = ['bom_part_id', 'bom_part_name'] 

1119 

1120 class Meta: 

1121 """Serializer metaclass.""" 

1122 

1123 model = BuildItem 

1124 fields = [ 

1125 'pk', 

1126 'build', 

1127 'build_line', 

1128 'install_into', 

1129 'stock_item', 

1130 'quantity', 

1131 'location', 

1132 # Detail fields, can be included or excluded 

1133 'build_detail', 

1134 'location_detail', 

1135 'part_detail', 

1136 'stock_item_detail', 

1137 'supplier_part_detail', 

1138 'install_into_detail', 

1139 # The following fields are only used for data export 

1140 'bom_reference', 

1141 'bom_part_id', 

1142 'bom_part_name', 

1143 ] 

1144 

1145 # Export-only fields 

1146 bom_reference = serializers.CharField( 

1147 source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True 

1148 ) 

1149 

1150 # BOM Item Part ID (it may be different to the allocated part) 

1151 bom_part_id = serializers.PrimaryKeyRelatedField( 

1152 source='build_line.bom_item.sub_part', 

1153 label=_('BOM Part ID'), 

1154 many=False, 

1155 read_only=True, 

1156 ) 

1157 

1158 bom_part_name = serializers.CharField( 

1159 source='build_line.bom_item.sub_part.name', 

1160 label=_('BOM Part Name'), 

1161 read_only=True, 

1162 ) 

1163 

1164 # Annotated fields 

1165 build = serializers.PrimaryKeyRelatedField( 

1166 source='build_line.build', many=False, read_only=True 

1167 ) 

1168 

1169 # Extra (optional) detail fields 

1170 part_detail = OptionalField( 

1171 serializer_class=part_serializers.PartBriefSerializer, 

1172 serializer_kwargs={ 

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

1174 'source': 'stock_item.part', 

1175 'many': False, 

1176 'read_only': True, 

1177 'allow_null': True, 

1178 'pricing': False, 

1179 }, 

1180 default_include=True, 

1181 prefetch_fields=['stock_item__part'], 

1182 ) 

1183 

1184 stock_item_detail = OptionalField( 

1185 serializer_class=StockItemSerializer, 

1186 serializer_kwargs={ 

1187 'source': 'stock_item', 

1188 'read_only': True, 

1189 'allow_null': True, 

1190 'label': _('Stock Item'), 

1191 'part_detail': False, 

1192 'location_detail': False, 

1193 'supplier_part_detail': False, 

1194 'path_detail': False, 

1195 }, 

1196 default_include=True, 

1197 filter_name='stock_detail', 

1198 prefetch_fields=[ 

1199 'stock_item', 

1200 'stock_item__part', 

1201 'stock_item__supplier_part', 

1202 'stock_item__supplier_part__manufacturer_part', 

1203 ], 

1204 ) 

1205 

1206 install_into_detail = OptionalField( 

1207 serializer_class=StockItemSerializer, 

1208 serializer_kwargs={ 

1209 'source': 'install_into', 

1210 'read_only': True, 

1211 'allow_null': True, 

1212 'label': _('Install Into'), 

1213 'part_detail': False, 

1214 'location_detail': False, 

1215 'supplier_part_detail': False, 

1216 'path_detail': False, 

1217 }, 

1218 default_include=False, 

1219 prefetch_fields=['install_into', 'install_into__part'], 

1220 ) 

1221 

1222 location = serializers.PrimaryKeyRelatedField( 

1223 label=_('Location'), source='stock_item.location', many=False, read_only=True 

1224 ) 

1225 

1226 location_detail = OptionalField( 

1227 serializer_class=LocationBriefSerializer, 

1228 serializer_kwargs={ 

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

1230 'source': 'stock_item.location', 

1231 'read_only': True, 

1232 'allow_null': True, 

1233 }, 

1234 default_include=True, 

1235 prefetch_fields=['stock_item__location'], 

1236 ) 

1237 

1238 build_detail = OptionalField( 

1239 serializer_class=BuildSerializer, 

1240 serializer_kwargs={ 

1241 'label': _('Build'), 

1242 'source': 'build_line.build', 

1243 'many': False, 

1244 'read_only': True, 

1245 'allow_null': True, 

1246 }, 

1247 default_include=True, 

1248 prefetch_fields=[ 

1249 'build_line__build', 

1250 'build_line__build__part', 

1251 'build_line__build__responsible', 

1252 'build_line__build__issued_by', 

1253 'build_line__build__project_code', 

1254 'build_line__build__part__pricing_data', 

1255 ], 

1256 ) 

1257 

1258 supplier_part_detail = OptionalField( 

1259 serializer_class=company.serializers.SupplierPartSerializer, 

1260 serializer_kwargs={ 

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

1262 'source': 'stock_item.supplier_part', 

1263 'many': False, 

1264 'read_only': True, 

1265 'allow_null': True, 

1266 'brief': True, 

1267 }, 

1268 default_include=False, 

1269 prefetch_fields=[ 

1270 'stock_item__supplier_part', 

1271 'stock_item__supplier_part__supplier', 

1272 'stock_item__supplier_part__manufacturer_part', 

1273 'stock_item__supplier_part__manufacturer_part__manufacturer', 

1274 ], 

1275 ) 

1276 

1277 quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) 

1278 

1279 

1280class BuildLineSerializer( 

1281 FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer 

1282): 

1283 """Serializer for a BuildItem object.""" 

1284 

1285 export_exclude_fields = ['allocations'] 

1286 

1287 export_child_fields = [ 

1288 'build_detail.reference', 

1289 'part_detail.name', 

1290 'part_detail.description', 

1291 'part_detail.IPN', 

1292 'part_detail.category', 

1293 'bom_item_detail.reference', 

1294 ] 

1295 

1296 export_only_fields = ['part_category_name'] 

1297 

1298 class Meta: 

1299 """Serializer metaclass.""" 

1300 

1301 model = BuildLine 

1302 fields = [ 

1303 'pk', 

1304 'build', 

1305 'bom_item', 

1306 'quantity', 

1307 'consumed', 

1308 'allocations', 

1309 'part', 

1310 # Build detail fields 

1311 'build_reference', 

1312 # BOM item detail fields 

1313 'reference', 

1314 'consumable', 

1315 'optional', 

1316 'testable', 

1317 'trackable', 

1318 'inherited', 

1319 'allow_variants', 

1320 # Annotated fields 

1321 'allocated', 

1322 'in_production', 

1323 'scheduled_to_build', 

1324 'on_order', 

1325 'available_stock', 

1326 'available_substitute_stock', 

1327 'available_variant_stock', 

1328 'external_stock', 

1329 # Related fields 

1330 'allocations', 

1331 # Extra fields only for data export 

1332 'part_category_name', 

1333 # Extra detail (related field) serializers 

1334 'bom_item_detail', 

1335 'assembly_detail', 

1336 'part_detail', 

1337 'category_detail', 

1338 'build_detail', 

1339 ] 

1340 read_only_fields = ['build', 'bom_item', 'allocations'] 

1341 

1342 # Build info fields 

1343 build_reference = serializers.CharField( 

1344 source='build.reference', label=_('Build Reference'), read_only=True 

1345 ) 

1346 

1347 # Part info fields 

1348 part = serializers.PrimaryKeyRelatedField( 

1349 source='bom_item.sub_part', label=_('Part'), many=False, read_only=True 

1350 ) 

1351 

1352 part_category_name = serializers.CharField( 

1353 source='bom_item.sub_part.category.name', 

1354 label=_('Part Category Name'), 

1355 read_only=True, 

1356 ) 

1357 

1358 allocations = OptionalField( 

1359 serializer_class=BuildItemSerializer, 

1360 serializer_kwargs={ 

1361 'many': True, 

1362 'read_only': True, 

1363 'allow_null': True, 

1364 'build_detail': False, 

1365 }, 

1366 default_include=True, 

1367 prefetch_fields=[ 

1368 'allocations', 

1369 'allocations__stock_item', 

1370 'allocations__stock_item__part', 

1371 'allocations__stock_item__part__pricing_data', 

1372 'allocations__stock_item__supplier_part', 

1373 'allocations__stock_item__supplier_part__manufacturer_part', 

1374 'allocations__stock_item__location', 

1375 ], 

1376 ) 

1377 

1378 # BOM item info fields 

1379 reference = serializers.CharField( 

1380 source='bom_item.reference', label=_('Reference'), read_only=True 

1381 ) 

1382 consumable = serializers.BooleanField( 

1383 source='bom_item.consumable', label=_('Consumable'), read_only=True 

1384 ) 

1385 optional = serializers.BooleanField( 

1386 source='bom_item.optional', label=_('Optional'), read_only=True 

1387 ) 

1388 testable = serializers.BooleanField( 

1389 source='bom_item.sub_part.testable', label=_('Testable'), read_only=True 

1390 ) 

1391 trackable = serializers.BooleanField( 

1392 source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True 

1393 ) 

1394 inherited = serializers.BooleanField( 

1395 source='bom_item.inherited', label=_('Inherited'), read_only=True 

1396 ) 

1397 allow_variants = serializers.BooleanField( 

1398 source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True 

1399 ) 

1400 

1401 quantity = serializers.FloatField(label=_('Quantity')) 

1402 consumed = serializers.FloatField(label=_('Consumed')) 

1403 

1404 bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) 

1405 

1406 # Foreign key fields 

1407 bom_item_detail = OptionalField( 

1408 serializer_class=part_serializers.BomItemSerializer, 

1409 serializer_kwargs={ 

1410 'label': _('BOM Item'), 

1411 'source': 'bom_item', 

1412 'many': False, 

1413 'read_only': True, 

1414 'allow_null': True, 

1415 'pricing': False, 

1416 'substitutes': False, 

1417 'sub_part_detail': False, 

1418 'part_detail': False, 

1419 'can_build': False, 

1420 }, 

1421 default_include=False, 

1422 prefetch_fields=['bom_item'], 

1423 ) 

1424 

1425 assembly_detail = OptionalField( 

1426 serializer_class=part_serializers.PartBriefSerializer, 

1427 serializer_kwargs={ 

1428 'label': _('Assembly'), 

1429 'source': 'bom_item.part', 

1430 'many': False, 

1431 'read_only': True, 

1432 'allow_null': True, 

1433 'pricing': False, 

1434 }, 

1435 default_include=False, 

1436 prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'], 

1437 ) 

1438 

1439 part_detail = OptionalField( 

1440 serializer_class=part_serializers.PartBriefSerializer, 

1441 serializer_kwargs={ 

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

1443 'source': 'bom_item.sub_part', 

1444 'many': False, 

1445 'read_only': True, 

1446 'allow_null': True, 

1447 'pricing': False, 

1448 }, 

1449 default_include=False, 

1450 prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'], 

1451 ) 

1452 

1453 category_detail = OptionalField( 

1454 serializer_class=part_serializers.CategorySerializer, 

1455 serializer_kwargs={ 

1456 'label': _('Category'), 

1457 'source': 'bom_item.sub_part.category', 

1458 'many': False, 

1459 'read_only': True, 

1460 'allow_null': True, 

1461 'path_detail': False, 

1462 }, 

1463 default_include=False, 

1464 prefetch_fields=['bom_item__sub_part__category'], 

1465 ) 

1466 

1467 build_detail = OptionalField( 

1468 serializer_class=BuildSerializer, 

1469 serializer_kwargs={ 

1470 'label': _('Build'), 

1471 'source': 'build', 

1472 'many': False, 

1473 'read_only': True, 

1474 'allow_null': True, 

1475 'part_detail': False, 

1476 'user_detail': False, 

1477 'project_code_detail': False, 

1478 }, 

1479 default_include=True, 

1480 ) 

1481 

1482 # Annotated (calculated) fields 

1483 

1484 # Total quantity of allocated stock 

1485 allocated = serializers.FloatField(label=_('Allocated'), read_only=True) 

1486 

1487 on_order = serializers.FloatField(label=_('On Order'), read_only=True) 

1488 in_production = serializers.FloatField(label=_('In Production'), read_only=True) 

1489 scheduled_to_build = serializers.FloatField( 

1490 label=_('Scheduled to Build'), read_only=True 

1491 ) 

1492 

1493 external_stock = serializers.FloatField(read_only=True, label=_('External Stock')) 

1494 available_stock = serializers.FloatField(read_only=True, label=_('Available Stock')) 

1495 available_substitute_stock = serializers.FloatField( 

1496 read_only=True, label=_('Available Substitute Stock') 

1497 ) 

1498 available_variant_stock = serializers.FloatField( 

1499 read_only=True, label=_('Available Variant Stock') 

1500 ) 

1501 

1502 @staticmethod 

1503 def annotate_queryset(queryset, build=None): 

1504 """Add extra annotations to the queryset. 

1505 

1506 Annotations: 

1507 - allocated: Total stock quantity allocated against this build line 

1508 - available: Total stock available for allocation against this build line 

1509 - on_order: Total stock on order for this build line 

1510 - in_production: Total stock currently in production for this build line 

1511 - scheduled_to_build: Total stock scheduled to be built for this build line 

1512 

1513 Arguments: 

1514 queryset: The queryset to annotate 

1515 build: The build order to filter against (optional) 

1516 

1517 Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build 

1518 

1519 """ 

1520 queryset = queryset.select_related( 

1521 'build', 

1522 'build__part', 

1523 'build__part__pricing_data', 

1524 'bom_item', 

1525 'bom_item__part', 

1526 'bom_item__part__pricing_data', 

1527 'bom_item__sub_part', 

1528 'bom_item__sub_part__pricing_data', 

1529 ) 

1530 

1531 # Defer expensive fields which we do not need for this serializer 

1532 

1533 queryset = queryset.defer( 

1534 'build__notes', 

1535 'build__metadata', 

1536 'bom_item__metadata', 

1537 'bom_item__part__notes', 

1538 'bom_item__part__metadata', 

1539 'bom_item__sub_part__notes', 

1540 'bom_item__sub_part__metadata', 

1541 ) 

1542 

1543 # Annotate the "allocated" quantity 

1544 queryset = queryset.annotate( 

1545 allocated=Coalesce( 

1546 Sum('allocations__quantity'), 0, output_field=models.DecimalField() 

1547 ) 

1548 ) 

1549 

1550 ref = 'bom_item__sub_part__' 

1551 

1552 stock_filter = None 

1553 

1554 if build is not None and build.take_from is not None: 1554 ↛ 1555line 1554 didn't jump to line 1555 because the condition on line 1554 was never true

1555 location = build.take_from 

1556 # Filter by locations below the specified location 

1557 stock_filter = Q( 

1558 location__tree_id=location.tree_id, 

1559 location__lft__gte=location.lft, 

1560 location__rght__lte=location.rght, 

1561 location__level__gte=location.level, 

1562 ) 

1563 else: 

1564 location = None 

1565 

1566 # Annotate the "in_production" quantity 

1567 queryset = queryset.annotate( 

1568 in_production=part.filters.annotate_in_production_quantity(reference=ref), 

1569 scheduled_to_build=part.filters.annotate_scheduled_to_build_quantity( 

1570 reference=ref 

1571 ), 

1572 ) 

1573 

1574 # Annotate the "on_order" quantity 

1575 queryset = queryset.annotate( 

1576 on_order=part.filters.annotate_on_order_quantity(reference=ref) 

1577 ) 

1578 

1579 # Annotate the "available" quantity 

1580 queryset = queryset.alias( 

1581 total_stock=part.filters.annotate_total_stock( 

1582 reference=ref, filter=stock_filter 

1583 ), 

1584 allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( 

1585 reference=ref, location=location 

1586 ), 

1587 allocated_to_build_orders=part.filters.annotate_build_order_allocations( 

1588 reference=ref, location=location 

1589 ), 

1590 ) 

1591 

1592 # Calculate 'available_stock' based on previously annotated fields 

1593 queryset = queryset.annotate( 

1594 available_stock=Greatest( 

1595 ExpressionWrapper( 

1596 F('total_stock') 

1597 - F('allocated_to_sales_orders') 

1598 - F('allocated_to_build_orders'), 

1599 output_field=models.DecimalField(), 

1600 ), 

1601 0, 

1602 output_field=models.DecimalField(), 

1603 ) 

1604 ) 

1605 

1606 external_stock_filter = Q(location__external=True) 

1607 

1608 if stock_filter: 1608 ↛ 1609line 1608 didn't jump to line 1609 because the condition on line 1608 was never true

1609 external_stock_filter &= stock_filter 

1610 

1611 # Add 'external stock' annotations 

1612 queryset = queryset.annotate( 

1613 external_stock=part.filters.annotate_total_stock( 

1614 reference=ref, filter=external_stock_filter 

1615 ) 

1616 ) 

1617 

1618 ref = 'bom_item__substitutes__part__' 

1619 

1620 # Extract similar information for any 'substitute' parts 

1621 queryset = queryset.alias( 

1622 substitute_stock=part.filters.annotate_total_stock( 

1623 reference=ref, filter=stock_filter 

1624 ), 

1625 substitute_build_allocations=part.filters.annotate_build_order_allocations( 

1626 reference=ref 

1627 ), 

1628 substitute_sales_allocations=part.filters.annotate_sales_order_allocations( 

1629 reference=ref 

1630 ), 

1631 ) 

1632 

1633 # Calculate 'available_substitute_stock' field 

1634 queryset = queryset.annotate( 

1635 available_substitute_stock=Greatest( 

1636 ExpressionWrapper( 

1637 F('substitute_stock') 

1638 - F('substitute_build_allocations') 

1639 - F('substitute_sales_allocations'), 

1640 output_field=models.DecimalField(), 

1641 ), 

1642 0, 

1643 output_field=models.DecimalField(), 

1644 ) 

1645 ) 

1646 

1647 # Annotate the queryset with 'available variant stock' information 

1648 variant_stock_query = part.filters.variant_stock_query( 

1649 reference='bom_item__sub_part__', filter=stock_filter 

1650 ) 

1651 

1652 queryset = queryset.alias( 

1653 variant_stock_total=part.filters.annotate_variant_quantity( 

1654 variant_stock_query, reference='quantity' 

1655 ), 

1656 variant_bo_allocations=part.filters.annotate_variant_quantity( 

1657 variant_stock_query, reference='sales_order_allocations__quantity' 

1658 ), 

1659 variant_so_allocations=part.filters.annotate_variant_quantity( 

1660 variant_stock_query, reference='allocations__quantity' 

1661 ), 

1662 ) 

1663 

1664 queryset = queryset.annotate( 

1665 available_variant_stock=Greatest( 

1666 ExpressionWrapper( 

1667 F('variant_stock_total') 

1668 - F('variant_bo_allocations') 

1669 - F('variant_so_allocations'), 

1670 output_field=FloatField(), 

1671 ), 

1672 0, 

1673 output_field=FloatField(), 

1674 ) 

1675 ) 

1676 

1677 return queryset 

1678 

1679 

1680class BuildConsumeAllocationSerializer(serializers.Serializer): 

1681 """Serializer for an individual BuildItem to be consumed against a BuildOrder.""" 

1682 

1683 class Meta: 

1684 """Serializer metaclass.""" 

1685 

1686 fields = ['build_item', 'quantity'] 

1687 

1688 build_item = serializers.PrimaryKeyRelatedField( 

1689 queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True 

1690 ) 

1691 

1692 quantity = serializers.DecimalField( 

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

1694 ) 

1695 

1696 def validate_quantity(self, quantity): 

1697 """Perform validation on the 'quantity' field.""" 

1698 if quantity <= 0: 

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

1700 

1701 return quantity 

1702 

1703 def validate(self, data): 

1704 """Validate the serializer data.""" 

1705 data = super().validate(data) 

1706 

1707 build_item = data['build_item'] 

1708 quantity = data['quantity'] 

1709 

1710 if quantity > build_item.quantity: 

1711 raise ValidationError({ 

1712 'quantity': _('Consumed quantity exceeds allocated quantity') 

1713 }) 

1714 

1715 return data 

1716 

1717 

1718class BuildConsumeLineItemSerializer(serializers.Serializer): 

1719 """Serializer for an individual BuildLine to be consumed against a BuildOrder.""" 

1720 

1721 class Meta: 

1722 """Serializer metaclass.""" 

1723 

1724 fields = ['build_line'] 

1725 

1726 build_line = serializers.PrimaryKeyRelatedField( 

1727 queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True 

1728 ) 

1729 

1730 

1731class BuildConsumeSerializer(serializers.Serializer): 

1732 """Serializer for consuming allocations against a BuildOrder. 

1733 

1734 - Consumes allocated stock items, increasing the 'consumed' field for each BuildLine. 

1735 - Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects. 

1736 """ 

1737 

1738 class Meta: 

1739 """Serializer metaclass.""" 

1740 

1741 fields = ['items', 'lines', 'notes'] 

1742 

1743 items = BuildConsumeAllocationSerializer(many=True, required=False) 

1744 

1745 lines = BuildConsumeLineItemSerializer(many=True, required=False) 

1746 

1747 notes = serializers.CharField( 

1748 label=_('Notes'), 

1749 help_text=_('Optional notes for the stock consumption'), 

1750 required=False, 

1751 allow_blank=True, 

1752 ) 

1753 

1754 def validate_items(self, items): 

1755 """Validate the BuildItem list passed to the serializer.""" 

1756 build_order = self.context['build'] 

1757 

1758 seen = set() 

1759 

1760 for item in items: 

1761 build_item = item['build_item'] 

1762 

1763 # BuildItem must point to the correct build order 

1764 if build_item.build != build_order: 

1765 raise ValidationError( 

1766 _('Build item must point to the correct build order') 

1767 ) 

1768 

1769 # Prevent duplicate item allocation 

1770 if build_item.pk in seen: 

1771 raise ValidationError(_('Duplicate build item allocation')) 

1772 

1773 seen.add(build_item.pk) 

1774 

1775 return items 

1776 

1777 def validate_lines(self, lines): 

1778 """Validate the BuildLine list passed to the serializer.""" 

1779 build_order = self.context['build'] 

1780 

1781 seen = set() 

1782 

1783 for line in lines: 

1784 build_line = line['build_line'] 

1785 

1786 # BuildLine must point to the correct build order 

1787 if build_line.build != build_order: 

1788 raise ValidationError( 

1789 _('Build line must point to the correct build order') 

1790 ) 

1791 

1792 # Prevent duplicate line allocation 

1793 if build_line.pk in seen: 

1794 raise ValidationError(_('Duplicate build line allocation')) 

1795 

1796 seen.add(build_line.pk) 

1797 

1798 return lines 

1799 

1800 def validate(self, data): 

1801 """Validate the serializer data.""" 

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

1803 lines = data.get('lines', []) 

1804 

1805 if len(items) == 0 and len(lines) == 0: 

1806 raise ValidationError(_('At least one item or line must be provided')) 

1807 

1808 return data