Coverage for src/backend/InvenTree/company/models.py: 50%

315 statements  

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

1"""Company database model definitions.""" 

2 

3import os 

4from decimal import Decimal 

5from typing import TypedDict 

6 

7from django.apps import apps 

8from django.conf import settings 

9from django.core.exceptions import ValidationError 

10from django.core.validators import MinValueValidator 

11from django.db import models 

12from django.db.models import Q, Sum, UniqueConstraint 

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

14from django.dispatch import receiver 

15from django.urls import reverse 

16from django.utils.translation import gettext_lazy as _ 

17from django.utils.translation import pgettext_lazy as __ 

18 

19from moneyed import CURRENCIES 

20 

21import common.currency 

22import common.models 

23import InvenTree.conversion 

24import InvenTree.helpers 

25import InvenTree.models 

26import InvenTree.ready 

27import InvenTree.validators 

28import report.mixins 

29from common.currency import currency_code_default 

30from InvenTree.fields import InvenTreeURLField, RoundingDecimalField 

31from order.status_codes import PurchaseOrderStatusGroups 

32 

33 

34def rename_company_image(instance, filename): 

35 """Function to rename a company image after upload. 

36 

37 Args: 

38 instance: Company object 

39 filename: uploaded image filename 

40 

41 Returns: 

42 New image filename 

43 """ 

44 base = 'company_images' 

45 

46 ext = filename.split('.')[-1] if filename.count('.') > 0 else '' 

47 

48 fn = f'company_{instance.pk}_img' 

49 

50 if ext: 

51 fn += '.' + ext 

52 

53 return os.path.join(base, fn) 

54 

55 

56class CompanyReportContext(report.mixins.BaseReportContext, TypedDict): 

57 """Report context for the Company model. 

58 

59 Attributes: 

60 company: The Company object associated with this context 

61 name: The name of the Company 

62 description: A description of the Company 

63 website: The website URL for the Company 

64 phone: The contact phone number for the Company 

65 email: The contact email address for the Company 

66 address: The primary address associated with the Company 

67 """ 

68 

69 company: 'Company' 

70 name: str 

71 description: str 

72 website: str 

73 phone: str 

74 email: str 

75 address: str 

76 

77 

78class Company( 

79 InvenTree.models.InvenTreeAttachmentMixin, 

80 InvenTree.models.InvenTreeParameterMixin, 

81 InvenTree.models.InvenTreeNotesMixin, 

82 InvenTree.models.InvenTreeTagsMixin, 

83 report.mixins.InvenTreeReportMixin, 

84 InvenTree.models.InvenTreeImageMixin, 

85 InvenTree.models.InvenTreeMetadataModel, 

86): 

87 """A Company object represents an external company. 

88 

89 It may be a supplier or a customer or a manufacturer (or a combination) 

90 

91 - A supplier is a company from which parts can be purchased 

92 - A customer is a company to which parts can be sold 

93 - A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also) 

94 

95 

96 Attributes: 

97 name: Brief name of the company 

98 description: Longer form description 

99 website: URL for the company website 

100 address: One-line string representation of primary address 

101 phone: contact phone number 

102 email: contact email address 

103 link: Secondary URL e.g. for link to internal Wiki page 

104 image: Company image / logo 

105 notes: Extra notes about the company 

106 active: boolean value, is this company active 

107 is_customer: boolean value, is this company a customer 

108 is_supplier: boolean value, is this company a supplier 

109 is_manufacturer: boolean value, is this company a manufacturer 

110 currency_code: Specifies the default currency for the company 

111 tax_id: Tax ID for the company 

112 """ 

113 

114 IMAGE_RENAME = rename_company_image 

115 IMPORT_ID_FIELDS = ['name'] 

116 

117 class Meta: 

118 """Metaclass defines extra model options.""" 

119 

120 ordering = ['name'] 

121 constraints = [ 

122 UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') 

123 ] 

124 verbose_name = _('Company') 

125 verbose_name_plural = _('Companies') 

126 

127 @staticmethod 

128 def get_api_url(): 

129 """Return the API URL associated with the Company model.""" 

130 return reverse('api-company-list') 

131 

132 def report_context(self) -> CompanyReportContext: 

133 """Generate a dict of context data to provide to the reporting framework.""" 

134 return { 

135 'company': self, 

136 'name': self.name, 

137 'description': self.description, 

138 'website': self.website, 

139 'phone': self.phone, 

140 'email': self.email, 

141 'address': str(self.address) if self.address else '', 

142 } 

143 

144 name = models.CharField( 

145 max_length=100, 

146 blank=False, 

147 help_text=_('Company name'), 

148 verbose_name=_('Company name'), 

149 ) 

150 

151 description = models.CharField( 

152 max_length=500, 

153 verbose_name=_('Company description'), 

154 help_text=_('Description of the company'), 

155 blank=True, 

156 ) 

157 

158 website = InvenTreeURLField( 

159 blank=True, 

160 verbose_name=_('Website'), 

161 help_text=_('Company website URL'), 

162 max_length=2000, 

163 ) 

164 

165 phone = models.CharField( 

166 max_length=50, 

167 verbose_name=_('Phone number'), 

168 blank=True, 

169 help_text=_('Contact phone number'), 

170 ) 

171 

172 email = models.EmailField( 

173 blank=True, 

174 null=True, 

175 verbose_name=_('Email'), 

176 help_text=_('Contact email address'), 

177 ) 

178 

179 contact = models.CharField( 

180 max_length=100, 

181 verbose_name=_('Contact'), 

182 blank=True, 

183 help_text=_('Point of contact'), 

184 ) 

185 

186 link = InvenTreeURLField( 

187 blank=True, 

188 verbose_name=_('Link'), 

189 help_text=_('Link to external company information'), 

190 max_length=2000, 

191 ) 

192 

193 active = models.BooleanField( 

194 default=True, verbose_name=_('Active'), help_text=_('Is this company active?') 

195 ) 

196 

197 is_customer = models.BooleanField( 

198 default=False, 

199 verbose_name=_('Is customer'), 

200 help_text=_('Do you sell items to this company?'), 

201 ) 

202 

203 is_supplier = models.BooleanField( 

204 default=True, 

205 verbose_name=_('Is supplier'), 

206 help_text=_('Do you purchase items from this company?'), 

207 ) 

208 

209 is_manufacturer = models.BooleanField( 

210 default=False, 

211 verbose_name=_('Is manufacturer'), 

212 help_text=_('Does this company manufacture parts?'), 

213 ) 

214 

215 currency = models.CharField( 

216 max_length=3, 

217 verbose_name=_('Currency'), 

218 blank=True, 

219 default=currency_code_default, 

220 help_text=_('Default currency used for this company'), 

221 validators=[InvenTree.validators.validate_currency_code], 

222 ) 

223 

224 tax_id = models.CharField( 

225 max_length=50, 

226 blank=True, 

227 verbose_name=_('Tax ID'), 

228 help_text=_('Company Tax ID'), 

229 ) 

230 

231 @property 

232 def address(self): 

233 """Return the string representation for the primary address. 

234 

235 This property exists for backwards compatibility 

236 """ 

237 addr = self.primary_address 

238 

239 return str(addr) if addr is not None else None 

240 

241 @property 

242 def primary_address(self): 

243 """Returns address object of primary address for this Company.""" 

244 # We may have a pre-fetched primary address list 

245 if hasattr(self, 'primary_address_list'): 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 addresses = self.primary_address_list 

247 return ( 

248 addresses[0] 

249 if len(addresses) > 0 and isinstance(addresses, list) 

250 else None 

251 ) 

252 

253 # Otherwise, query the database 

254 return self.addresses.filter(primary=True).first() 

255 

256 @property 

257 def currency_code(self): 

258 """Return the currency code associated with this company. 

259 

260 - If the currency code is invalid, use the default currency 

261 - If the currency code is not specified, use the default currency 

262 """ 

263 code = self.currency 

264 

265 if code not in CURRENCIES: 

266 code = common.currency.currency_code_default() 

267 

268 return code 

269 

270 def __str__(self): 

271 """Get string representation of a Company.""" 

272 return f'{self.name} - {self.description}' 

273 

274 def get_absolute_url(self): 

275 """Get the web URL for the detail view for this Company.""" 

276 return InvenTree.helpers.pui_url(f'/purchasing/manufacturer/{self.id}') 

277 

278 @property 

279 def parts(self): 

280 """Return SupplierPart objects which are supplied or manufactured by this company.""" 

281 return SupplierPart.objects.filter( 

282 Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id) 

283 ).distinct() 

284 

285 @property 

286 def stock_items(self): 

287 """Return a list of all stock items supplied or manufactured by this company.""" 

288 stock = apps.get_model('stock', 'StockItem') 

289 return stock.objects.filter( 

290 Q(supplier_part__supplier=self.id) 

291 | Q(supplier_part__manufacturer_part__manufacturer=self.id) 

292 ).distinct() 

293 

294 

295class Contact(InvenTree.models.InvenTreeMetadataModel): 

296 """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects. 

297 

298 Attributes: 

299 company: Company link for this contact 

300 name: Name of the contact 

301 phone: contact phone number 

302 email: contact email 

303 role: position in company 

304 """ 

305 

306 IMPORT_ID_FIELDS = ['name', 'email'] 

307 

308 class Meta: 

309 """Metaclass defines extra model options.""" 

310 

311 verbose_name = _('Contact') 

312 

313 @staticmethod 

314 def get_api_url(): 

315 """Return the API URL associated with the Contact model.""" 

316 return reverse('api-contact-list') 

317 

318 company = models.ForeignKey( 

319 Company, related_name='contacts', on_delete=models.CASCADE 

320 ) 

321 

322 name = models.CharField(max_length=100) 

323 

324 phone = models.CharField(max_length=100, blank=True) 

325 

326 email = models.EmailField(blank=True) 

327 

328 role = models.CharField(max_length=100, blank=True) 

329 

330 

331class Address(InvenTree.models.InvenTreeModel): 

332 """An address represents a physical location where the company is located. It is possible for a company to have multiple locations. 

333 

334 Attributes: 

335 company: Company link for this address 

336 title: Human-readable name for the address 

337 primary: True if this is the company's primary address 

338 line1: First line of address 

339 line2: Optional line two for address 

340 postal_code: Postal code, city and state 

341 country: Location country 

342 shipping_notes: Notes for couriers transporting shipments to this address 

343 internal_shipping_notes: Internal notes regarding shipping to this address 

344 link: External link to additional address information 

345 """ 

346 

347 class Meta: 

348 """Metaclass defines extra model options.""" 

349 

350 verbose_name = _('Address') 

351 verbose_name_plural = _('Addresses') 

352 

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

354 """Custom init function.""" 

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

356 

357 def __str__(self): 

358 """Defines string representation of address to supple a one-line to API calls.""" 

359 available_lines = [ 

360 self.line1, 

361 self.line2, 

362 self.postal_code, 

363 self.postal_city, 

364 self.province, 

365 self.country, 

366 ] 

367 

368 populated_lines = [] 

369 for line in available_lines: 

370 if len(line) > 0: 

371 populated_lines.append(line) 

372 

373 return ', '.join(populated_lines) 

374 

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

376 """Run checks when saving an address. 

377 

378 Rules: 

379 - If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary 

380 """ 

381 others = Address.objects.filter(company=self.company).exclude(pk=self.pk) 

382 

383 # If this is the *only* address for this company, make it the primary one 

384 if not others.exists(): 

385 self.primary = True 

386 

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

388 

389 # Once this address is saved, check others 

390 if self.primary: 

391 Address.objects.filter(company=self.company).exclude(pk=self.pk).filter( 

392 primary=True 

393 ).update(primary=False) 

394 

395 @staticmethod 

396 def get_api_url(): 

397 """Return the API URL associated with the Contact model.""" 

398 return reverse('api-address-list') 

399 

400 company = models.ForeignKey( 

401 Company, 

402 related_name='addresses', 

403 on_delete=models.CASCADE, 

404 verbose_name=_('Company'), 

405 help_text=_('Select company'), 

406 ) 

407 

408 title = models.CharField( 

409 max_length=100, 

410 verbose_name=_('Address title'), 

411 help_text=_('Title describing the address entry'), 

412 blank=False, 

413 ) 

414 

415 primary = models.BooleanField( 

416 default=False, 

417 verbose_name=_('Primary address'), 

418 help_text=_('Set as primary address'), 

419 ) 

420 

421 line1 = models.CharField( 

422 max_length=50, 

423 verbose_name=_('Line 1'), 

424 help_text=_('Address line 1'), 

425 blank=True, 

426 ) 

427 

428 line2 = models.CharField( 

429 max_length=50, 

430 verbose_name=_('Line 2'), 

431 help_text=_('Address line 2'), 

432 blank=True, 

433 ) 

434 

435 postal_code = models.CharField( 

436 max_length=10, 

437 verbose_name=_('Postal code'), 

438 help_text=_('Postal code'), 

439 blank=True, 

440 ) 

441 

442 postal_city = models.CharField( 

443 max_length=50, 

444 verbose_name=_('City/Region'), 

445 help_text=_('Postal code city/region'), 

446 blank=True, 

447 ) 

448 

449 province = models.CharField( 

450 max_length=50, 

451 verbose_name=_('State/Province'), 

452 help_text=_('State or province'), 

453 blank=True, 

454 ) 

455 

456 country = models.CharField( 

457 max_length=50, 

458 verbose_name=_('Country'), 

459 help_text=_('Address country'), 

460 blank=True, 

461 ) 

462 

463 shipping_notes = models.CharField( 

464 max_length=100, 

465 verbose_name=_('Courier shipping notes'), 

466 help_text=_('Notes for shipping courier'), 

467 blank=True, 

468 ) 

469 

470 internal_shipping_notes = models.CharField( 

471 max_length=100, 

472 verbose_name=_('Internal shipping notes'), 

473 help_text=_('Shipping notes for internal use'), 

474 blank=True, 

475 ) 

476 

477 link = InvenTreeURLField( 

478 blank=True, 

479 verbose_name=_('Link'), 

480 help_text=_('Link to address information (external)'), 

481 max_length=2000, 

482 ) 

483 

484 

485class ManufacturerPart( 

486 InvenTree.models.InvenTreeAttachmentMixin, 

487 InvenTree.models.InvenTreeParameterMixin, 

488 InvenTree.models.InvenTreeBarcodeMixin, 

489 InvenTree.models.InvenTreeNotesMixin, 

490 InvenTree.models.InvenTreeTagsMixin, 

491 InvenTree.models.InvenTreeMetadataModel, 

492): 

493 """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. 

494 

495 Attributes: 

496 part: Link to the master Part 

497 manufacturer: Company that manufactures the ManufacturerPart 

498 MPN: Manufacture part number 

499 link: Link to external website for this manufacturer part 

500 description: Descriptive notes field 

501 """ 

502 

503 IMPORT_ID_FIELDS = ['MPN'] 

504 

505 class Meta: 

506 """Metaclass defines extra model options.""" 

507 

508 verbose_name = _('Manufacturer Part') 

509 unique_together = ('part', 'manufacturer', 'MPN') 

510 

511 @staticmethod 

512 def get_api_url(): 

513 """Return the API URL associated with the ManufacturerPart instance.""" 

514 return reverse('api-manufacturer-part-list') 

515 

516 @classmethod 

517 def barcode_model_type_code(cls): 

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

519 return 'MP' 

520 

521 part = models.ForeignKey( 

522 'part.Part', 

523 on_delete=models.CASCADE, 

524 related_name='manufacturer_parts', 

525 verbose_name=_('Base Part'), 

526 limit_choices_to={'purchaseable': True}, 

527 help_text=_('Select part'), 

528 ) 

529 

530 manufacturer = models.ForeignKey( 

531 Company, 

532 on_delete=models.CASCADE, 

533 null=True, 

534 related_name='manufactured_parts', 

535 limit_choices_to={'is_manufacturer': True}, 

536 verbose_name=_('Manufacturer'), 

537 help_text=_('Select manufacturer'), 

538 ) 

539 

540 MPN = models.CharField( 

541 null=True, 

542 max_length=100, 

543 verbose_name=_('MPN'), 

544 help_text=_('Manufacturer Part Number'), 

545 ) 

546 

547 link = InvenTreeURLField( 

548 blank=True, 

549 null=True, 

550 verbose_name=_('Link'), 

551 help_text=_('URL for external manufacturer part link'), 

552 max_length=2000, 

553 ) 

554 

555 description = models.CharField( 

556 max_length=250, 

557 blank=True, 

558 null=True, 

559 verbose_name=_('Description'), 

560 help_text=_('Manufacturer part description'), 

561 ) 

562 

563 @classmethod 

564 def create(cls, part, manufacturer, mpn, description, link=None): 

565 """Check if ManufacturerPart instance does not already exist then create it.""" 

566 manufacturer_part = None 

567 

568 try: 

569 manufacturer_part = ManufacturerPart.objects.get( 

570 part=part, manufacturer=manufacturer, MPN=mpn 

571 ) 

572 except ManufacturerPart.DoesNotExist: 

573 pass 

574 

575 if not manufacturer_part: 

576 manufacturer_part = ManufacturerPart( 

577 part=part, 

578 manufacturer=manufacturer, 

579 MPN=mpn, 

580 description=description, 

581 link=link, 

582 ) 

583 manufacturer_part.save() 

584 

585 return manufacturer_part 

586 

587 def __str__(self): 

588 """Format a string representation of a ManufacturerPart.""" 

589 s = '' 

590 

591 if self.manufacturer: 

592 s += f'{self.manufacturer.name}' 

593 s += ' | ' 

594 

595 s += f'{self.MPN}' 

596 

597 return s 

598 

599 

600class SupplierPart( 

601 InvenTree.models.InvenTreeAttachmentMixin, 

602 InvenTree.models.InvenTreeParameterMixin, 

603 InvenTree.models.MetadataMixin, 

604 InvenTree.models.InvenTreeBarcodeMixin, 

605 InvenTree.models.InvenTreeTagsMixin, 

606 InvenTree.models.InvenTreeNotesMixin, 

607 common.models.MetaMixin, 

608 InvenTree.models.InvenTreeModel, 

609): 

610 """Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers. 

611 

612 Attributes: 

613 part: Link to the master Part (Obsolete) 

614 source_item: The sourcing item linked to this SupplierPart instance 

615 supplier: Company that supplies this SupplierPart object 

616 active: Boolean value, is this supplier part active 

617 primary: Boolean value, is this the primary supplier part for the linked Part 

618 SKU: Stock keeping unit (supplier part number) 

619 link: Link to external website for this supplier part 

620 description: Descriptive notes field 

621 note: Longer form note field 

622 base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" 

623 multiple: Multiple that the part is provided in 

624 packaging: packaging that the part is supplied in, e.g. "Reel" 

625 pack_quantity: Quantity of item supplied in a single pack (e.g. 30ml in a single tube) 

626 pack_quantity_native: Pack quantity, converted to "native" units of the referenced part 

627 updated: Date that the SupplierPart was last updated 

628 """ 

629 

630 IMPORT_ID_FIELDS = ['SKU'] 

631 

632 class Meta: 

633 """Metaclass defines extra model options.""" 

634 

635 unique_together = ('part', 'supplier', 'SKU') 

636 

637 verbose_name = _('Supplier Part') 

638 

639 # This model was moved from the 'Part' app 

640 db_table = 'part_supplierpart' 

641 

642 @staticmethod 

643 def get_api_url(): 

644 """Return the API URL associated with the SupplierPart model.""" 

645 return reverse('api-supplier-part-list') 

646 

647 def get_absolute_url(self): 

648 """Return the web URL of the detail view for this SupplierPart.""" 

649 return InvenTree.helpers.pui_url(f'/purchasing/supplier-part/{self.id}') 

650 

651 def api_instance_filters(self): 

652 """Return custom API filters for this particular instance.""" 

653 return {'manufacturer_part': {'part': self.part.pk}} 

654 

655 @classmethod 

656 def barcode_model_type_code(cls): 

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

658 return 'SP' 

659 

660 def clean(self): 

661 """Custom clean action for the SupplierPart model. 

662 

663 Rules: 

664 - Ensure that manufacturer_part.part and part are the same! 

665 """ 

666 super().clean() 

667 

668 self.pack_quantity = self.pack_quantity.strip() 

669 

670 # An empty 'pack_quantity' value is equivalent to '1' 

671 if self.pack_quantity == '': 

672 self.pack_quantity = '1' 

673 

674 # Validate that the UOM is compatible with the base part 

675 if self.pack_quantity and self.part: 

676 try: 

677 # Attempt conversion to specified unit 

678 native_value = InvenTree.conversion.convert_physical_value( 

679 self.pack_quantity, self.part.units, strip_units=False 

680 ) 

681 

682 # If part units are not provided, value must be dimensionless 

683 if not self.part.units and not InvenTree.conversion.is_dimensionless( 

684 native_value 

685 ): 

686 raise ValidationError({ 

687 'pack_quantity': _( 

688 'Pack units must be compatible with the base part units' 

689 ) 

690 }) 

691 

692 # Native value must be greater than zero 

693 if float(native_value.magnitude) <= 0: 

694 raise ValidationError({ 

695 'pack_quantity': _('Pack units must be greater than zero') 

696 }) 

697 

698 # Update native pack units value 

699 self.pack_quantity_native = Decimal(native_value.magnitude) 

700 

701 except ValidationError as e: 

702 raise ValidationError({'pack_quantity': e.messages}) 

703 

704 # Ensure that the linked manufacturer_part points to the same part! 

705 if self.manufacturer_part and self.part: 

706 if self.manufacturer_part.part != self.part: 

707 raise ValidationError({ 

708 'manufacturer_part': _( 

709 'Linked manufacturer part must reference the same base part' 

710 ) 

711 }) 

712 

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

714 """Overriding save method to connect an existing ManufacturerPart.""" 

715 manufacturer_part = None 

716 

717 if all(key in kwargs for key in ('manufacturer', 'MPN')): 

718 manufacturer_name = kwargs.pop('manufacturer') 

719 MPN = kwargs.pop('MPN') 

720 

721 # Retrieve manufacturer part 

722 try: 

723 manufacturer_part = ManufacturerPart.objects.get( 

724 manufacturer__name=manufacturer_name, MPN=MPN 

725 ) 

726 except (ValueError, Company.DoesNotExist): 

727 # ManufacturerPart does not exist 

728 pass 

729 

730 if manufacturer_part: 

731 if not self.manufacturer_part: 

732 # Connect ManufacturerPart to SupplierPart 

733 self.manufacturer_part = manufacturer_part 

734 else: 

735 raise ValidationError( 

736 f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}' 

737 ) 

738 

739 self.clean() 

740 self.validate_unique() 

741 

742 # Ensure that only one SupplierPart is marked as "primary" for a given Part 

743 others = SupplierPart.objects.filter(part=self.part).exclude(pk=self.pk) 

744 

745 # If this is the *only* SupplierPart for this Part, make it the primary one 

746 if not others.exists(): 

747 self.primary = True 

748 

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

750 

751 # Once this SupplierPart is saved, check others 

752 if self.primary: 

753 SupplierPart.objects.filter(part=self.part).exclude(pk=self.pk).filter( 

754 primary=True 

755 ).update(primary=False) 

756 

757 part = models.ForeignKey( 

758 'part.Part', 

759 on_delete=models.CASCADE, 

760 related_name='supplier_parts', 

761 verbose_name=_('Base Part'), 

762 limit_choices_to={'purchaseable': True}, 

763 help_text=_('Select part'), 

764 ) 

765 

766 supplier = models.ForeignKey( 

767 Company, 

768 on_delete=models.CASCADE, 

769 related_name='supplied_parts', 

770 limit_choices_to={'is_supplier': True}, 

771 verbose_name=_('Supplier'), 

772 help_text=_('Select supplier'), 

773 ) 

774 

775 SKU = models.CharField( 

776 max_length=100, 

777 verbose_name=__('SKU = Stock Keeping Unit (supplier part number)', 'SKU'), 

778 help_text=_('Supplier stock keeping unit'), 

779 ) 

780 

781 active = models.BooleanField( 

782 default=True, 

783 verbose_name=_('Active'), 

784 help_text=_('Is this supplier part active?'), 

785 ) 

786 

787 primary = models.BooleanField( 

788 default=False, 

789 verbose_name=_('Primary'), 

790 help_text=_('Is this the primary supplier part for the linked Part?'), 

791 ) 

792 

793 manufacturer_part = models.ForeignKey( 

794 ManufacturerPart, 

795 on_delete=models.CASCADE, 

796 blank=True, 

797 null=True, 

798 related_name='supplier_parts', 

799 verbose_name=_('Manufacturer Part'), 

800 help_text=_('Select manufacturer part'), 

801 ) 

802 

803 link = InvenTreeURLField( 

804 blank=True, 

805 null=True, 

806 verbose_name=_('Link'), 

807 help_text=_('URL for external supplier part link'), 

808 max_length=2000, 

809 ) 

810 

811 description = models.CharField( 

812 max_length=250, 

813 blank=True, 

814 null=True, 

815 verbose_name=_('Description'), 

816 help_text=_('Supplier part description'), 

817 ) 

818 

819 note = models.CharField( 

820 max_length=100, 

821 blank=True, 

822 null=True, 

823 verbose_name=_('Note'), 

824 help_text=_('Notes'), 

825 ) 

826 

827 base_cost = models.DecimalField( 

828 max_digits=10, 

829 decimal_places=3, 

830 default=0, 

831 validators=[MinValueValidator(0)], 

832 verbose_name=_('base cost'), 

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

834 ) 

835 

836 packaging = models.CharField( 

837 max_length=50, 

838 blank=True, 

839 null=True, 

840 verbose_name=_('Packaging'), 

841 help_text=_('Part packaging'), 

842 ) 

843 

844 pack_quantity = models.CharField( 

845 max_length=25, 

846 verbose_name=_('Pack Quantity'), 

847 help_text=_( 

848 'Total quantity supplied in a single pack. Leave empty for single items.' 

849 ), 

850 blank=True, 

851 ) 

852 

853 pack_quantity_native = RoundingDecimalField( 

854 max_digits=20, decimal_places=10, default=1, null=True 

855 ) 

856 

857 def base_quantity(self, quantity=1) -> Decimal: 

858 """Calculate the base unit quantity for a given quantity.""" 

859 q = Decimal(quantity) * Decimal(self.pack_quantity_native) 

860 q = round(q, 10).normalize() 

861 

862 return q 

863 

864 multiple = models.PositiveIntegerField( 

865 default=1, 

866 validators=[MinValueValidator(1)], 

867 verbose_name=_('multiple'), 

868 help_text=_('Order multiple'), 

869 ) 

870 

871 # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). 

872 # lead_time = models.DurationField(blank=True, null=True) 

873 

874 available = models.DecimalField( 

875 max_digits=10, 

876 decimal_places=3, 

877 default=0, 

878 validators=[MinValueValidator(0)], 

879 verbose_name=_('Available'), 

880 help_text=_('Quantity available from supplier'), 

881 ) 

882 

883 availability_updated = models.DateTimeField( 

884 null=True, 

885 blank=True, 

886 verbose_name=_('Availability Updated'), 

887 help_text=_('Date of last update of availability data'), 

888 ) 

889 

890 def update_available_quantity(self, quantity): 

891 """Update the available quantity for this SupplierPart.""" 

892 self.available = quantity 

893 self.availability_updated = InvenTree.helpers.current_time() 

894 self.save() 

895 

896 @property 

897 def name(self): 

898 """Return string representation of own name.""" 

899 return str(self) 

900 

901 @property 

902 def manufacturer_string(self): 

903 """Format a MPN string for this SupplierPart. 

904 

905 Concatenates manufacture name and part number. 

906 """ 

907 items = [] 

908 

909 if self.manufacturer_part: 

910 if self.manufacturer_part.manufacturer: 

911 items.append(self.manufacturer_part.manufacturer.name) 

912 if self.manufacturer_part.MPN: 

913 items.append(self.manufacturer_part.MPN) 

914 

915 return ' | '.join(items) 

916 

917 @property 

918 def has_price_breaks(self): 

919 """Return True if this SupplierPart has associated price breaks.""" 

920 return self.price_breaks.count() > 0 

921 

922 @property 

923 def price_breaks(self): 

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

925 return self.pricebreaks.order_by('quantity').all() 

926 

927 @property 

928 def unit_pricing(self): 

929 """Return the single-quantity pricing for this SupplierPart.""" 

930 return self.get_price(1) 

931 

932 def add_price_break(self, quantity, price) -> None: 

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

934 

935 Args: 

936 quantity: Numerical quantity 

937 price: Must be a Money object 

938 """ 

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

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

941 return 

942 

943 SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price) 

944 

945 get_price = common.currency.get_price 

946 

947 def open_orders(self): 

948 """Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding.""" 

949 return self.purchase_order_line_items.prefetch_related('order').filter( 

950 order__status__in=PurchaseOrderStatusGroups.OPEN 

951 ) 

952 

953 def on_order(self): 

954 """Return the total quantity of items currently on order. 

955 

956 Subtract partially received stock as appropriate 

957 """ 

958 totals = self.open_orders().aggregate(Sum('quantity'), Sum('received')) 

959 

960 # Quantity on order 

961 q = totals.get('quantity__sum', 0) 

962 

963 # Quantity received 

964 r = totals.get('received__sum', 0) 

965 

966 if q is None or r is None: 

967 return 0 

968 return max(q - r, 0) 

969 

970 def purchase_orders(self): 

971 """Returns a list of purchase orders relating to this supplier part.""" 

972 return [ 

973 line.order 

974 for line in self.purchase_order_line_items.all().prefetch_related('order') 

975 ] 

976 

977 @property 

978 def pretty_name(self): 

979 """Format a 'pretty' name for this SupplierPart.""" 

980 return str(self) 

981 

982 def __str__(self): 

983 """Format a string representation of a SupplierPart.""" 

984 s = '' 

985 

986 if self.part.IPN: 

987 s += f'{self.part.IPN}' 

988 s += ' | ' 

989 

990 s += f'{self.supplier.name} | {self.SKU}' 

991 

992 if self.manufacturer_string: 

993 s = s + ' | ' + self.manufacturer_string 

994 

995 return s 

996 

997 

998class SupplierPriceBreak(common.models.PriceBreak): 

999 """Represents a quantity price break for a SupplierPart. 

1000 

1001 - Suppliers can offer discounts at larger quantities 

1002 - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) 

1003 

1004 Attributes: 

1005 part: Link to a SupplierPart object that this price break applies to 

1006 updated: Automatic DateTime field that shows last time the price break was updated 

1007 quantity: Quantity required for price break 

1008 cost: Cost at specified quantity 

1009 currency: Reference to the currency of this pricebreak (leave empty for base currency) 

1010 """ 

1011 

1012 class Meta: 

1013 """Metaclass defines extra model options.""" 

1014 

1015 verbose_name = _('Supplier Price Break') 

1016 unique_together = ('part', 'quantity') 

1017 

1018 # This model was moved from the 'Part' app 

1019 db_table = 'part_supplierpricebreak' 

1020 

1021 def __str__(self): 

1022 """Format a string representation of a SupplierPriceBreak instance.""" 

1023 return f'{self.part.SKU} - {self.price} @ {self.quantity}' 

1024 

1025 @staticmethod 

1026 def get_api_url(): 

1027 """Return the API URL associated with the SupplierPriceBreak model.""" 

1028 return reverse('api-part-supplier-price-list') 

1029 

1030 part = models.ForeignKey( 

1031 SupplierPart, 

1032 on_delete=models.CASCADE, 

1033 related_name='pricebreaks', 

1034 verbose_name=_('Part'), 

1035 ) 

1036 

1037 

1038@receiver( 

1039 post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break' 

1040) 

1041def after_save_supplier_price(sender, instance, created, **kwargs): 

1042 """Callback function when a SupplierPriceBreak is created or updated.""" 

1043 if ( 

1044 ( 

1045 InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) 

1046 and not InvenTree.ready.isImportingData() 

1047 ) 

1048 and instance.part 

1049 and instance.part.part 

1050 ): 

1051 instance.part.part.schedule_pricing_update(create=True) 

1052 

1053 

1054@receiver( 

1055 post_delete, 

1056 sender=SupplierPriceBreak, 

1057 dispatch_uid='post_delete_supplier_price_break', 

1058) 

1059def after_delete_supplier_price(sender, instance, **kwargs): 

1060 """Callback function when a SupplierPriceBreak is deleted.""" 

1061 if ( 

1062 ( 

1063 InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) 

1064 and not InvenTree.ready.isImportingData() 

1065 ) 

1066 and instance.part 

1067 and instance.part.part 

1068 ): 

1069 instance.part.part.schedule_pricing_update(create=False)