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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Company database model definitions."""
3import os
4from decimal import Decimal
5from typing import TypedDict
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 __
19from moneyed import CURRENCIES
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
34def rename_company_image(instance, filename):
35 """Function to rename a company image after upload.
37 Args:
38 instance: Company object
39 filename: uploaded image filename
41 Returns:
42 New image filename
43 """
44 base = 'company_images'
46 ext = filename.split('.')[-1] if filename.count('.') > 0 else ''
48 fn = f'company_{instance.pk}_img'
50 if ext:
51 fn += '.' + ext
53 return os.path.join(base, fn)
56class CompanyReportContext(report.mixins.BaseReportContext, TypedDict):
57 """Report context for the Company model.
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 """
69 company: 'Company'
70 name: str
71 description: str
72 website: str
73 phone: str
74 email: str
75 address: str
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.
89 It may be a supplier or a customer or a manufacturer (or a combination)
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)
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 """
114 IMAGE_RENAME = rename_company_image
115 IMPORT_ID_FIELDS = ['name']
117 class Meta:
118 """Metaclass defines extra model options."""
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')
127 @staticmethod
128 def get_api_url():
129 """Return the API URL associated with the Company model."""
130 return reverse('api-company-list')
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 }
144 name = models.CharField(
145 max_length=100,
146 blank=False,
147 help_text=_('Company name'),
148 verbose_name=_('Company name'),
149 )
151 description = models.CharField(
152 max_length=500,
153 verbose_name=_('Company description'),
154 help_text=_('Description of the company'),
155 blank=True,
156 )
158 website = InvenTreeURLField(
159 blank=True,
160 verbose_name=_('Website'),
161 help_text=_('Company website URL'),
162 max_length=2000,
163 )
165 phone = models.CharField(
166 max_length=50,
167 verbose_name=_('Phone number'),
168 blank=True,
169 help_text=_('Contact phone number'),
170 )
172 email = models.EmailField(
173 blank=True,
174 null=True,
175 verbose_name=_('Email'),
176 help_text=_('Contact email address'),
177 )
179 contact = models.CharField(
180 max_length=100,
181 verbose_name=_('Contact'),
182 blank=True,
183 help_text=_('Point of contact'),
184 )
186 link = InvenTreeURLField(
187 blank=True,
188 verbose_name=_('Link'),
189 help_text=_('Link to external company information'),
190 max_length=2000,
191 )
193 active = models.BooleanField(
194 default=True, verbose_name=_('Active'), help_text=_('Is this company active?')
195 )
197 is_customer = models.BooleanField(
198 default=False,
199 verbose_name=_('Is customer'),
200 help_text=_('Do you sell items to this company?'),
201 )
203 is_supplier = models.BooleanField(
204 default=True,
205 verbose_name=_('Is supplier'),
206 help_text=_('Do you purchase items from this company?'),
207 )
209 is_manufacturer = models.BooleanField(
210 default=False,
211 verbose_name=_('Is manufacturer'),
212 help_text=_('Does this company manufacture parts?'),
213 )
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 )
224 tax_id = models.CharField(
225 max_length=50,
226 blank=True,
227 verbose_name=_('Tax ID'),
228 help_text=_('Company Tax ID'),
229 )
231 @property
232 def address(self):
233 """Return the string representation for the primary address.
235 This property exists for backwards compatibility
236 """
237 addr = self.primary_address
239 return str(addr) if addr is not None else None
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 )
253 # Otherwise, query the database
254 return self.addresses.filter(primary=True).first()
256 @property
257 def currency_code(self):
258 """Return the currency code associated with this company.
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
265 if code not in CURRENCIES:
266 code = common.currency.currency_code_default()
268 return code
270 def __str__(self):
271 """Get string representation of a Company."""
272 return f'{self.name} - {self.description}'
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}')
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()
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()
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.
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 """
306 IMPORT_ID_FIELDS = ['name', 'email']
308 class Meta:
309 """Metaclass defines extra model options."""
311 verbose_name = _('Contact')
313 @staticmethod
314 def get_api_url():
315 """Return the API URL associated with the Contact model."""
316 return reverse('api-contact-list')
318 company = models.ForeignKey(
319 Company, related_name='contacts', on_delete=models.CASCADE
320 )
322 name = models.CharField(max_length=100)
324 phone = models.CharField(max_length=100, blank=True)
326 email = models.EmailField(blank=True)
328 role = models.CharField(max_length=100, blank=True)
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.
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 """
347 class Meta:
348 """Metaclass defines extra model options."""
350 verbose_name = _('Address')
351 verbose_name_plural = _('Addresses')
353 def __init__(self, *args, **kwargs):
354 """Custom init function."""
355 super().__init__(*args, **kwargs)
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 ]
368 populated_lines = []
369 for line in available_lines:
370 if len(line) > 0:
371 populated_lines.append(line)
373 return ', '.join(populated_lines)
375 def save(self, *args, **kwargs):
376 """Run checks when saving an address.
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)
383 # If this is the *only* address for this company, make it the primary one
384 if not others.exists():
385 self.primary = True
387 super().save(*args, **kwargs)
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)
395 @staticmethod
396 def get_api_url():
397 """Return the API URL associated with the Contact model."""
398 return reverse('api-address-list')
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 )
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 )
415 primary = models.BooleanField(
416 default=False,
417 verbose_name=_('Primary address'),
418 help_text=_('Set as primary address'),
419 )
421 line1 = models.CharField(
422 max_length=50,
423 verbose_name=_('Line 1'),
424 help_text=_('Address line 1'),
425 blank=True,
426 )
428 line2 = models.CharField(
429 max_length=50,
430 verbose_name=_('Line 2'),
431 help_text=_('Address line 2'),
432 blank=True,
433 )
435 postal_code = models.CharField(
436 max_length=10,
437 verbose_name=_('Postal code'),
438 help_text=_('Postal code'),
439 blank=True,
440 )
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 )
449 province = models.CharField(
450 max_length=50,
451 verbose_name=_('State/Province'),
452 help_text=_('State or province'),
453 blank=True,
454 )
456 country = models.CharField(
457 max_length=50,
458 verbose_name=_('Country'),
459 help_text=_('Address country'),
460 blank=True,
461 )
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 )
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 )
477 link = InvenTreeURLField(
478 blank=True,
479 verbose_name=_('Link'),
480 help_text=_('Link to address information (external)'),
481 max_length=2000,
482 )
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.
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 """
503 IMPORT_ID_FIELDS = ['MPN']
505 class Meta:
506 """Metaclass defines extra model options."""
508 verbose_name = _('Manufacturer Part')
509 unique_together = ('part', 'manufacturer', 'MPN')
511 @staticmethod
512 def get_api_url():
513 """Return the API URL associated with the ManufacturerPart instance."""
514 return reverse('api-manufacturer-part-list')
516 @classmethod
517 def barcode_model_type_code(cls):
518 """Return the associated barcode model type code for this model."""
519 return 'MP'
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 )
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 )
540 MPN = models.CharField(
541 null=True,
542 max_length=100,
543 verbose_name=_('MPN'),
544 help_text=_('Manufacturer Part Number'),
545 )
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 )
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 )
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
568 try:
569 manufacturer_part = ManufacturerPart.objects.get(
570 part=part, manufacturer=manufacturer, MPN=mpn
571 )
572 except ManufacturerPart.DoesNotExist:
573 pass
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()
585 return manufacturer_part
587 def __str__(self):
588 """Format a string representation of a ManufacturerPart."""
589 s = ''
591 if self.manufacturer:
592 s += f'{self.manufacturer.name}'
593 s += ' | '
595 s += f'{self.MPN}'
597 return s
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.
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 """
630 IMPORT_ID_FIELDS = ['SKU']
632 class Meta:
633 """Metaclass defines extra model options."""
635 unique_together = ('part', 'supplier', 'SKU')
637 verbose_name = _('Supplier Part')
639 # This model was moved from the 'Part' app
640 db_table = 'part_supplierpart'
642 @staticmethod
643 def get_api_url():
644 """Return the API URL associated with the SupplierPart model."""
645 return reverse('api-supplier-part-list')
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}')
651 def api_instance_filters(self):
652 """Return custom API filters for this particular instance."""
653 return {'manufacturer_part': {'part': self.part.pk}}
655 @classmethod
656 def barcode_model_type_code(cls):
657 """Return the associated barcode model type code for this model."""
658 return 'SP'
660 def clean(self):
661 """Custom clean action for the SupplierPart model.
663 Rules:
664 - Ensure that manufacturer_part.part and part are the same!
665 """
666 super().clean()
668 self.pack_quantity = self.pack_quantity.strip()
670 # An empty 'pack_quantity' value is equivalent to '1'
671 if self.pack_quantity == '':
672 self.pack_quantity = '1'
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 )
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 })
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 })
698 # Update native pack units value
699 self.pack_quantity_native = Decimal(native_value.magnitude)
701 except ValidationError as e:
702 raise ValidationError({'pack_quantity': e.messages})
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 })
713 def save(self, *args, **kwargs):
714 """Overriding save method to connect an existing ManufacturerPart."""
715 manufacturer_part = None
717 if all(key in kwargs for key in ('manufacturer', 'MPN')):
718 manufacturer_name = kwargs.pop('manufacturer')
719 MPN = kwargs.pop('MPN')
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
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 )
739 self.clean()
740 self.validate_unique()
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)
745 # If this is the *only* SupplierPart for this Part, make it the primary one
746 if not others.exists():
747 self.primary = True
749 super().save(*args, **kwargs)
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)
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 )
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 )
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 )
781 active = models.BooleanField(
782 default=True,
783 verbose_name=_('Active'),
784 help_text=_('Is this supplier part active?'),
785 )
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 )
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 )
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 )
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 )
819 note = models.CharField(
820 max_length=100,
821 blank=True,
822 null=True,
823 verbose_name=_('Note'),
824 help_text=_('Notes'),
825 )
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 )
836 packaging = models.CharField(
837 max_length=50,
838 blank=True,
839 null=True,
840 verbose_name=_('Packaging'),
841 help_text=_('Part packaging'),
842 )
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 )
853 pack_quantity_native = RoundingDecimalField(
854 max_digits=20, decimal_places=10, default=1, null=True
855 )
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()
862 return q
864 multiple = models.PositiveIntegerField(
865 default=1,
866 validators=[MinValueValidator(1)],
867 verbose_name=_('multiple'),
868 help_text=_('Order multiple'),
869 )
871 # TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
872 # lead_time = models.DurationField(blank=True, null=True)
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 )
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 )
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()
896 @property
897 def name(self):
898 """Return string representation of own name."""
899 return str(self)
901 @property
902 def manufacturer_string(self):
903 """Format a MPN string for this SupplierPart.
905 Concatenates manufacture name and part number.
906 """
907 items = []
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)
915 return ' | '.join(items)
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
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()
927 @property
928 def unit_pricing(self):
929 """Return the single-quantity pricing for this SupplierPart."""
930 return self.get_price(1)
932 def add_price_break(self, quantity, price) -> None:
933 """Create a new price break for this part.
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
943 SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price)
945 get_price = common.currency.get_price
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 )
953 def on_order(self):
954 """Return the total quantity of items currently on order.
956 Subtract partially received stock as appropriate
957 """
958 totals = self.open_orders().aggregate(Sum('quantity'), Sum('received'))
960 # Quantity on order
961 q = totals.get('quantity__sum', 0)
963 # Quantity received
964 r = totals.get('received__sum', 0)
966 if q is None or r is None:
967 return 0
968 return max(q - r, 0)
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 ]
977 @property
978 def pretty_name(self):
979 """Format a 'pretty' name for this SupplierPart."""
980 return str(self)
982 def __str__(self):
983 """Format a string representation of a SupplierPart."""
984 s = ''
986 if self.part.IPN:
987 s += f'{self.part.IPN}'
988 s += ' | '
990 s += f'{self.supplier.name} | {self.SKU}'
992 if self.manufacturer_string:
993 s = s + ' | ' + self.manufacturer_string
995 return s
998class SupplierPriceBreak(common.models.PriceBreak):
999 """Represents a quantity price break for a SupplierPart.
1001 - Suppliers can offer discounts at larger quantities
1002 - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
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 """
1012 class Meta:
1013 """Metaclass defines extra model options."""
1015 verbose_name = _('Supplier Price Break')
1016 unique_together = ('part', 'quantity')
1018 # This model was moved from the 'Part' app
1019 db_table = 'part_supplierpricebreak'
1021 def __str__(self):
1022 """Format a string representation of a SupplierPriceBreak instance."""
1023 return f'{self.part.SKU} - {self.price} @ {self.quantity}'
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')
1030 part = models.ForeignKey(
1031 SupplierPart,
1032 on_delete=models.CASCADE,
1033 related_name='pricebreaks',
1034 verbose_name=_('Part'),
1035 )
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)
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)