861 lines
40 KiB
Python
861 lines
40 KiB
Python
# models.py
|
|
from django.db import models
|
|
from django.core.exceptions import ValidationError
|
|
# Import settings to reference the AUTH_USER_MODEL
|
|
from django.conf import settings
|
|
import re # For custom validation if needed
|
|
|
|
# It's good practice to define choices as constants
|
|
ADDRESS_TYPES = [
|
|
('billing', 'Billing'),
|
|
('shipping', 'Shipping'),
|
|
('physical', 'Physical'),
|
|
# Add other types as needed
|
|
]
|
|
|
|
# USER_TYPES constant removed as we now have separate models
|
|
|
|
EMAIL_RECIPIENT_TYPES = [
|
|
('to', 'To'),
|
|
('cc', 'Cc'),
|
|
('from', 'From'), # Added 'from' for consistency
|
|
# ('bcc', 'Bcc'), # Usually not stored directly
|
|
]
|
|
|
|
# --- Base Models (like Currency, Country, State first as others depend on them) ---
|
|
|
|
class Currency(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
code = models.CharField(max_length=3, unique=True, help_text="ISO 4217 currency code (e.g., AUD, USD)")
|
|
symbol = models.CharField(max_length=5, blank=True, null=True)
|
|
|
|
class Meta:
|
|
db_table = 'currencies' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Currencies" # Correct pluralization
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.code})"
|
|
|
|
class Country(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
# code = models.CharField(max_length=2, unique=True, help_text="ISO 3166-1 alpha-2 code (e.g., AU, US)")
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='countries', null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'countries' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Countries" # Correct pluralization
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class State(models.Model):
|
|
name = models.CharField(max_length=100)
|
|
shortform = models.CharField(max_length=10, unique=True, help_text="Abbreviation (e.g., NSW, VIC, CA)")
|
|
# country = models.ForeignKey(Country, on_delete=models.CASCADE, related_name='states') # Added link to country
|
|
|
|
class Meta:
|
|
db_table = 'states' # CakePHP convention
|
|
ordering = ['name']
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
class Status(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
# 'class' is a reserved keyword in Python, rename the field
|
|
css_class = models.CharField(max_length=50, blank=True, null=True, help_text="CSS class for styling", db_column="class")
|
|
|
|
class Meta:
|
|
db_table = 'statuses' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Statuses"
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
# Helper methods from CakePHP model can be added here or as manager methods if needed
|
|
# def get_json(self): ...
|
|
# def get_class_names_json(self): ...
|
|
|
|
class CustomerCategory(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
|
|
class Meta:
|
|
db_table = 'customer_categories' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Customer Categories"
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class ContactCategory(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
|
|
class Meta:
|
|
db_table = 'contact_categories' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Contact Categories"
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Industry(models.Model):
|
|
name = models.CharField(max_length=150)
|
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='sub_industries')
|
|
|
|
class Meta:
|
|
db_table = 'industries' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Industries"
|
|
|
|
def __str__(self):
|
|
if self.parent:
|
|
return f"{self.parent} -> {self.name}"
|
|
return self.name
|
|
|
|
class ProductCategory(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
|
|
class Meta:
|
|
db_table = 'product_categories' # CakePHP convention
|
|
ordering = ['name']
|
|
verbose_name_plural = "Product Categories"
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class FreightForwarder(models.Model):
|
|
name = models.CharField(max_length=150, unique=True)
|
|
|
|
class Meta:
|
|
db_table = 'freight_forwarders' # CakePHP convention
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class FreightService(models.Model):
|
|
name = models.CharField(max_length=100)
|
|
freight_forwarder = models.ForeignKey(FreightForwarder, on_delete=models.CASCADE, related_name='freight_services')
|
|
|
|
class Meta:
|
|
db_table = 'freight_services' # CakePHP convention
|
|
ordering = ['freight_forwarder__name', 'name']
|
|
unique_together = ('name', 'freight_forwarder')
|
|
|
|
def __str__(self):
|
|
return f"{self.freight_forwarder.name} - {self.name}"
|
|
|
|
# --- Core Models ---
|
|
|
|
class Customer(models.Model):
|
|
name = models.CharField(max_length=200, unique=True)
|
|
abn = models.CharField(max_length=20, blank=True, null=True, help_text="Australian Business Number")
|
|
customer_category = models.ForeignKey(CustomerCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='customers')
|
|
country = models.ForeignKey(Country, on_delete=models.PROTECT, related_name='customers') # Assuming country is mandatory
|
|
payment_terms = models.CharField(max_length=100, blank=True, null=True)
|
|
# CakePHP HABTM
|
|
industries = models.ManyToManyField(Industry, related_name='customers', blank=True, db_table='customers_industries') # Explicit join table
|
|
created = models.DateTimeField(auto_now_add=True)
|
|
# updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'customers' # CakePHP convention
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def clean(self):
|
|
# Example implementation of checkABN validation
|
|
if self.abn:
|
|
numbers = re.sub(r"\s*\D*", "", self.abn)
|
|
if len(numbers) != 11:
|
|
raise ValidationError({'abn': 'ABN must contain exactly 11 digits.'})
|
|
super().clean()
|
|
|
|
class Address(models.Model):
|
|
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='addresses')
|
|
address_line_1 = models.CharField(max_length=255, verbose_name="Address")
|
|
address_line_2 = models.CharField(max_length=255, blank=True, null=True)
|
|
city = models.CharField(max_length=100)
|
|
postcode = models.CharField(max_length=20)
|
|
state = models.ForeignKey(State, on_delete=models.PROTECT, related_name='addresses') # Protect states from deletion if used
|
|
country = models.ForeignKey(Country, on_delete=models.PROTECT, related_name='addresses') # Protect countries
|
|
type = models.CharField(max_length=20, choices=ADDRESS_TYPES) # Use choices for type
|
|
|
|
class Meta:
|
|
db_table = 'addresses' # CakePHP convention
|
|
ordering = ['customer__name', 'type']
|
|
verbose_name_plural = "Addresses"
|
|
|
|
def __str__(self):
|
|
return f"{self.customer.name} - {self.get_type_display()} Address"
|
|
|
|
def clean(self):
|
|
# Implementation for checkStates validation
|
|
# Assumes Australia Country ID is 1 and Overseas State ID is 9 (adapt IDs if different)
|
|
# It's better to check by country code or name if IDs might change
|
|
is_australia = self.country.code == 'AU' # Example check by code
|
|
# Need a way to identify the 'Overseas' state reliably (e.g., a flag on the State model)
|
|
# is_overseas_state = self.state.is_overseas # Assuming an 'is_overseas' boolean field exists on State
|
|
|
|
# Placeholder logic - replace with reliable check
|
|
is_overseas_state_placeholder = self.state_id == 9
|
|
|
|
if is_australia and is_overseas_state_placeholder:
|
|
raise ValidationError("Australian addresses cannot use the 'Overseas' state.")
|
|
if not is_australia and not is_overseas_state_placeholder:
|
|
raise ValidationError("Non-Australian addresses must use the 'Overseas' state.")
|
|
super().clean()
|
|
|
|
class Principle(models.Model):
|
|
name = models.CharField(max_length=200, unique=True)
|
|
city = models.CharField(max_length=100, blank=True, null=True)
|
|
country = models.ForeignKey(Country, on_delete=models.PROTECT, related_name='principles')
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='principles')
|
|
|
|
class Meta:
|
|
db_table = 'principles' # CakePHP convention
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
# getCityCountryList can be a manager method or utility function
|
|
|
|
class PrincipleAddress(models.Model):
|
|
principle = models.ForeignKey(Principle, on_delete=models.CASCADE, related_name='addresses')
|
|
address_line_1 = models.CharField(max_length=255, verbose_name="Address")
|
|
address_line_2 = models.CharField(max_length=255, blank=True, null=True)
|
|
city = models.CharField(max_length=100)
|
|
postcode = models.CharField(max_length=20)
|
|
# Assuming state is not needed here based on Cake model, but could be added
|
|
country = models.ForeignKey(Country, on_delete=models.PROTECT, related_name='principle_addresses')
|
|
type = models.CharField(max_length=50, blank=True, null=True, help_text="e.g., Factory, Office") # Example type
|
|
|
|
class Meta:
|
|
db_table = 'principle_addresses' # CakePHP convention
|
|
ordering = ['principle__name']
|
|
verbose_name_plural = "Principle Addresses"
|
|
|
|
def __str__(self):
|
|
return f"Address for {self.principle.name}"
|
|
|
|
# --- NEW Contact Model (for external contacts) ---
|
|
class Contact(models.Model):
|
|
"""Represents an external contact person associated with a Customer or Principle."""
|
|
first_name = models.CharField(max_length=100)
|
|
last_name = models.CharField(max_length=100, blank=True, null=True)
|
|
email = models.EmailField(unique=True) # Assuming unique email for contacts
|
|
job_title = models.CharField(max_length=100, blank=True, null=True)
|
|
|
|
# Relationships
|
|
#principle = models.ForeignKey(Principle, on_delete=models.SET_NULL, null=True, blank=True, related_name='contacts')
|
|
customer = models.ForeignKey(Customer, on_delete=models.SET_NULL, null=True, blank=True, related_name='contacts')
|
|
contact_category = models.ForeignKey(ContactCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='contacts')
|
|
|
|
# Field from Vault script - indicates if created_at automatically
|
|
# by_vault = models.BooleanField(default=False)
|
|
|
|
#created_at = models.DateTimeField(auto_now_add=True)
|
|
#updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'contacts' # CakePHP convention (assuming this replaces the old 'users' for external contacts)
|
|
ordering = ['first_name', 'last_name']
|
|
verbose_name = "External Contact"
|
|
verbose_name_plural = "External Contacts"
|
|
|
|
def __str__(self):
|
|
company = ""
|
|
if self.customer:
|
|
company = f" ({self.customer.name})"
|
|
#elif self.principle:
|
|
# company = f" ({self.principle.name})"
|
|
return f"{self.first_name} {self.last_name or ''}{company} - {self.email}"
|
|
|
|
@property
|
|
def full_name(self):
|
|
return f"{self.first_name} {self.last_name or ''}".strip()
|
|
|
|
|
|
# --- Product Related Models ---
|
|
|
|
class Product(models.Model):
|
|
principle = models.ForeignKey(Principle, on_delete=models.CASCADE, related_name='products')
|
|
product_category = models.ForeignKey(ProductCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='products')
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True, null=True)
|
|
model_number = models.CharField(max_length=100, blank=True, null=True) # Assuming a model number field exists
|
|
|
|
class Meta:
|
|
db_table = 'products' # CakePHP convention
|
|
ordering = ['principle__name', 'name']
|
|
unique_together = ('principle', 'name') # Or model_number?
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.principle.name})"
|
|
|
|
class ProductAttachment(models.Model):
|
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='attachments')
|
|
# Use FileField for uploads. Requires MEDIA_ROOT/MEDIA_URL setup.
|
|
file = models.FileField(upload_to='product_attachments/')
|
|
description = models.CharField(max_length=255, blank=True, null=True)
|
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'product_attachments' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"Attachment for {self.product.name}"
|
|
|
|
class ProductOptionsCategory(models.Model):
|
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='option_categories')
|
|
name = models.CharField(max_length=100)
|
|
location = models.PositiveIntegerField(null=True, blank=True, help_text="Ordering position")
|
|
exclusive = models.BooleanField(default=False, help_text="Can only one option in this category be chosen?")
|
|
|
|
class Meta:
|
|
db_table = 'product_options_categories' # CakePHP convention
|
|
ordering = ['product__name', 'location', 'name']
|
|
verbose_name_plural = "Product Option Categories"
|
|
|
|
def __str__(self):
|
|
return f"{self.name} (Options for {self.product.name})"
|
|
|
|
class ProductOption(models.Model):
|
|
product_options_category = models.ForeignKey(ProductOptionsCategory, on_delete=models.CASCADE, related_name='options')
|
|
name = models.CharField(max_length=150)
|
|
value = models.CharField(max_length=255, blank=True, null=True, help_text="Value associated with the option, if any")
|
|
price_adjustment = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) # Example field
|
|
is_default = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
db_table = 'product_options' # CakePHP convention
|
|
ordering = ['product_options_category', 'name']
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.product_options_category.name})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Logic to enforce only one default option per category
|
|
if self.is_default:
|
|
ProductOption.objects.filter(
|
|
product_options_category=self.product_options_category,
|
|
is_default=True
|
|
).exclude(pk=self.pk).update(is_default=False)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
# --- Document / Enquiry / Job / Order Flow ---
|
|
|
|
class Document(models.Model):
|
|
# Represents the base for Quotes, Invoices, POs etc.
|
|
DOC_TYPES = [
|
|
('quote', 'Quotation'),
|
|
('invoice', 'Invoice'),
|
|
('purchaseOrder', 'Purchase Order'),
|
|
('orderAck', 'Order Acknowledgement'),
|
|
('packingList', 'Packing List'),
|
|
]
|
|
type = models.CharField(max_length=20, choices=DOC_TYPES)
|
|
# Link to the internal user who created_at/owns this document
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL, # Keep document if user is deleted? Or PROTECT?
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_at_documents',
|
|
help_text="Internal user who created_at this document"
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
# Counter cache fields need manual implementation
|
|
# doc_page_count = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
db_table = 'documents' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
# Try to get the title from the related specific document type
|
|
related_doc = None
|
|
if self.type == 'quote' and hasattr(self, 'quote'):
|
|
related_doc = self.quote
|
|
elif self.type == 'invoice' and hasattr(self, 'invoice'):
|
|
related_doc = self.invoice
|
|
elif self.type == 'purchaseOrder' and hasattr(self, 'purchaseorder'):
|
|
related_doc = self.purchaseorder
|
|
elif self.type == 'orderAck' and hasattr(self, 'orderacknowledgement'):
|
|
related_doc = self.orderacknowledgement
|
|
elif self.type == 'packingList' and hasattr(self, 'packinglist'):
|
|
related_doc = self.packinglist
|
|
|
|
title = getattr(related_doc, 'title', f"Document {self.id}") if related_doc else f"Document {self.id}"
|
|
return f"{self.get_type_display()} - {title}"
|
|
|
|
# Methods like getCurrency, getDocType, getEnquiry etc. can be added here
|
|
|
|
class DocPage(models.Model):
|
|
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name='pages')
|
|
page_number = models.PositiveIntegerField()
|
|
content = models.TextField(blank=True, null=True) # Or maybe link to a scanned image FileField?
|
|
|
|
class Meta:
|
|
db_table = 'doc_pages' # CakePHP convention
|
|
ordering = ['document', 'page_number']
|
|
unique_together = ('document', 'page_number')
|
|
|
|
def __str__(self):
|
|
return f"Page {self.page_number} of Document {self.document.id}"
|
|
|
|
class Attachment(models.Model):
|
|
# General attachment model, linked via DocumentAttachment
|
|
principle = models.ForeignKey(Principle, on_delete=models.CASCADE, related_name='attachments', null=True, blank=True) # If attachments can belong to principles
|
|
name = models.CharField(max_length=255) # Original filename or description
|
|
type = models.CharField(max_length=100) # MIME type
|
|
size = models.PositiveIntegerField()
|
|
file = models.FileField(upload_to='general_attachments/') # Actual file storage
|
|
archived = models.BooleanField(default=False)
|
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'attachments' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class DocumentAttachment(models.Model):
|
|
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name='doc_attachments') # Changed related_name to avoid clash
|
|
attachment = models.ForeignKey(Attachment, on_delete=models.CASCADE, related_name='document_links') # Link to the generic attachment
|
|
|
|
class Meta:
|
|
db_table = 'document_attachments' # Match CakePHP table name (already set)
|
|
unique_together = ('document', 'attachment')
|
|
|
|
def __str__(self):
|
|
return f"Attachment {self.attachment.name} linked to Document {self.document.id}"
|
|
|
|
|
|
class Enquiry(models.Model):
|
|
title = models.CharField(max_length=255, unique=True, help_text="Unique Enquiry reference (e.g., CMC...E...)")
|
|
# Link to the internal user who owns/created_at this enquiry
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.PROTECT, # Protect user from deletion if they have enquiries?
|
|
related_name='created_at_enquiries',
|
|
help_text="Internal user who owns/created_at this enquiry"
|
|
)
|
|
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='enquiries')
|
|
# Link to the external contact person for this enquiry
|
|
contact = models.ForeignKey( # Renamed from contact_user
|
|
Contact,
|
|
on_delete=models.PROTECT, # Protect contact if they are linked? Or SET_NULL?
|
|
related_name='contact_enquiries',
|
|
help_text="External contact person for this enquiry"
|
|
)
|
|
state = models.ForeignKey(State, on_delete=models.PROTECT, related_name='enquiries')
|
|
country = models.ForeignKey(Country, on_delete=models.PROTECT, related_name='enquiries')
|
|
principle = models.ForeignKey(Principle, on_delete=models.PROTECT, related_name='enquiries')
|
|
status = models.ForeignKey(Status, on_delete=models.PROTECT, related_name='enquiries')
|
|
billing_address = models.ForeignKey(Address, on_delete=models.SET_NULL, null=True, blank=True, related_name='billing_enquiries')
|
|
shipping_address = models.ForeignKey(Address, on_delete=models.SET_NULL, null=True, blank=True, related_name='shipping_enquiries')
|
|
gst = models.BooleanField(default=True, help_text="Does GST apply to this enquiry?")
|
|
comments = models.TextField(blank=True, null=True)
|
|
created = models.DateTimeField(auto_now_add=True)
|
|
# updated_at = models.DateTimeField(auto_now=True)
|
|
# counterCache fields need manual implementation
|
|
# quote_count = models.PositiveIntegerField(default=0)
|
|
# invoice_count = models.PositiveIntegerField(default=0)
|
|
# job_count = models.PositiveIntegerField(default=0)
|
|
# orderacknowledgement_count = models.PositiveIntegerField(default=0)
|
|
# packinglist_count = models.PositiveIntegerField(default=0)
|
|
|
|
|
|
class Meta:
|
|
db_table = 'enquiries' # CakePHP convention
|
|
ordering = ['-created']
|
|
verbose_name_plural = "Enquiries"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def clean(self):
|
|
# Implementation for checkStates validation (similar to Address)
|
|
is_australia = self.country.code == 'AU' # Example check by code
|
|
# Placeholder logic - replace with reliable check
|
|
is_overseas_state_placeholder = self.state_id == 9
|
|
|
|
if is_australia and is_overseas_state_placeholder:
|
|
raise ValidationError("Australian enquiries cannot use the 'Overseas' state.")
|
|
if not is_australia and not is_overseas_state_placeholder:
|
|
raise ValidationError("Non-Australian enquiries must use the 'Overseas' state.")
|
|
super().clean()
|
|
|
|
# formatAddress can be a model method or moved to templates/views
|
|
|
|
class EnquiryEmailQueue(models.Model):
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.CASCADE, related_name='email_queue')
|
|
email_subject = models.CharField(max_length=255)
|
|
email_body = models.TextField()
|
|
recipient_email = models.EmailField()
|
|
sent = models.BooleanField(default=False)
|
|
send_at = models.DateTimeField(null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'enquiry_email_queues' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"Email for Enquiry {self.enquiry.title} to {self.recipient_email}"
|
|
|
|
class EnquiryFile(models.Model):
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.CASCADE, related_name='files')
|
|
file = models.FileField(upload_to='enquiry_files/')
|
|
description = models.CharField(max_length=255, blank=True, null=True)
|
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'enquiry_files' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"File for Enquiry {self.enquiry.title}"
|
|
|
|
class Job(models.Model):
|
|
title = models.CharField(max_length=100, unique=True, help_text="Unique Job reference (e.g., JAN24...J...)")
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.SET_NULL, null=True, blank=True, related_name='jobs') # Job might exist without initial enquiry?
|
|
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='jobs')
|
|
# Link to the external contact person for this job
|
|
contact = models.ForeignKey(
|
|
Contact,
|
|
on_delete=models.SET_NULL, # Allow job to exist if contact is deleted?
|
|
null=True,
|
|
blank=True,
|
|
related_name='contact_jobs',
|
|
help_text="External contact person for this job"
|
|
)
|
|
state = models.ForeignKey(State, on_delete=models.PROTECT, related_name='jobs') # State where job is located?
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='jobs') # Currency of the job value?
|
|
customer_order_number = models.CharField(max_length=100, blank=True, null=True)
|
|
date_order_received = models.DateField(null=True, blank=True)
|
|
# Define choices for categories if applicable
|
|
# domestic_freight_paid_by = models.CharField(max_length=50, blank=True, null=True)
|
|
# sale_category = models.CharField(max_length=50, blank=True, null=True)
|
|
# shipment_category = models.CharField(max_length=50, blank=True, null=True)
|
|
all_sent = models.BooleanField(default=False)
|
|
all_paid = models.BooleanField(default=False)
|
|
comments = models.TextField(blank=True, null=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'jobs' # CakePHP convention
|
|
ordering = ['-date_order_received', 'title']
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
# newJob logic should be moved to a manager method or a service/utility function
|
|
|
|
class LineItem(models.Model):
|
|
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name='line_items')
|
|
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name='line_items', null=True, blank=True) # Protect product from deletion if used
|
|
item_number = models.PositiveIntegerField()
|
|
description = models.TextField(blank=True, null=True) # Allow manual description
|
|
quantity = models.DecimalField(max_digits=10, decimal_places=2) # Use Decimal for quantity if needed
|
|
unit_price = models.DecimalField(max_digits=12, decimal_places=2)
|
|
# Add fields like discount, tax rate if needed
|
|
|
|
class Meta:
|
|
db_table = 'line_items' # CakePHP convention
|
|
ordering = ['document', 'item_number']
|
|
unique_together = ('document', 'item_number')
|
|
|
|
def __str__(self):
|
|
return f"Item {self.item_number} for Doc {self.document.id}"
|
|
|
|
@property
|
|
def total_price(self):
|
|
return self.quantity * self.unit_price
|
|
|
|
class Costing(models.Model):
|
|
line_item = models.OneToOneField(LineItem, on_delete=models.CASCADE, related_name='costing')
|
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='costings', null=True, blank=True) # Denormalized? Already linked via line_item
|
|
purchase_currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='purchase_costings')
|
|
sale_currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='sale_costings')
|
|
purchase_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
|
sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) # Denormalized? Already in line_item
|
|
exchange_rate = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
|
# Add other costing fields (freight, duty etc.)
|
|
|
|
class Meta:
|
|
db_table = 'costings' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"Costing for Line Item {self.line_item.id}"
|
|
|
|
|
|
class Quote(models.Model):
|
|
# Represents the specific details of a Quotation document
|
|
document = models.OneToOneField(Document, on_delete=models.CASCADE, primary_key=True, related_name='quote') # Links to the base document
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.CASCADE, related_name='quotes') # Link back to the enquiry
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='quotes')
|
|
title = models.CharField(max_length=255, help_text="e.g., Quotation Number, can be same as Enquiry title")
|
|
revision = models.CharField(max_length=10, blank=True, null=True)
|
|
delivery_time = models.CharField(max_length=100, blank=True, null=True, verbose_name="Delivery Time")
|
|
payment_terms = models.CharField(max_length=100, blank=True, null=True, verbose_name="Payment Terms")
|
|
days_valid = models.PositiveIntegerField(null=True, blank=True, verbose_name="Validity (Days)")
|
|
issue_date = models.DateField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'quotes' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"Quote: {self.title}"
|
|
|
|
class Invoice(models.Model):
|
|
document = models.OneToOneField(Document, on_delete=models.CASCADE, primary_key=True, related_name='invoice')
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices') # Invoice might relate to multiple jobs/enquiries?
|
|
job = models.ForeignKey(Job, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices')
|
|
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='invoices')
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='invoices')
|
|
title = models.CharField(max_length=100, unique=True, help_text="Invoice Number (e.g., CMCIN...)")
|
|
issue_date = models.DateField()
|
|
due_date = models.DateField(null=True, blank=True)
|
|
paid = models.BooleanField(default=False)
|
|
date_paid = models.DateField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'invoices' # CakePHP convention
|
|
ordering = ['-issue_date']
|
|
|
|
def __str__(self):
|
|
return f"Invoice: {self.title}"
|
|
|
|
class PurchaseOrder(models.Model):
|
|
document = models.OneToOneField(Document, on_delete=models.CASCADE, primary_key=True, related_name='purchaseorder')
|
|
principle = models.ForeignKey(Principle, on_delete=models.PROTECT, related_name='purchase_orders')
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='purchase_orders')
|
|
title = models.CharField(max_length=100, unique=True, help_text="PO Number (e.g., CMCPO...)")
|
|
issue_date = models.DateField(null=True, blank=True)
|
|
# HABTM relationship defined via the Job model
|
|
jobs = models.ManyToManyField(Job, related_name='purchase_orders', blank=True, db_table='jobs_purchase_orders') # Explicit join table
|
|
|
|
class Meta:
|
|
db_table = 'purchase_orders' # CakePHP convention
|
|
ordering = ['-issue_date', 'title']
|
|
|
|
def __str__(self):
|
|
return f"PO: {self.title}"
|
|
|
|
class OrderAcknowledgement(models.Model):
|
|
document = models.OneToOneField(Document, on_delete=models.CASCADE, primary_key=True, related_name='orderacknowledgement')
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.SET_NULL, null=True, blank=True, related_name='order_acknowledgements')
|
|
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='order_acknowledgements') # Usually linked to one job
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='order_acknowledgements')
|
|
title = models.CharField(max_length=255, help_text="e.g., Order Acknowledgement Reference")
|
|
revision = models.CharField(max_length=10, blank=True, null=True)
|
|
delivery_time = models.CharField(max_length=100, blank=True, null=True, verbose_name="Est. Delivery Time")
|
|
payment_terms = models.CharField(max_length=100, blank=True, null=True, verbose_name="Payment Terms")
|
|
issue_date = models.DateField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'order_acknowledgements' # CakePHP convention
|
|
|
|
def __str__(self):
|
|
return f"Order Ack for Job: {self.job.title}"
|
|
|
|
class PackingList(models.Model):
|
|
document = models.OneToOneField(Document, on_delete=models.CASCADE, primary_key=True, related_name='packinglist')
|
|
enquiry = models.ForeignKey(Enquiry, on_delete=models.SET_NULL, null=True, blank=True, related_name='packing_lists')
|
|
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='packing_lists') # Usually linked to one job
|
|
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='packing_lists') # Customer receiving
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='packing_lists') # Currency for value declaration?
|
|
title = models.CharField(max_length=100, help_text="Packing List Reference")
|
|
issue_date = models.DateField()
|
|
|
|
class Meta:
|
|
db_table = 'packing_lists' # CakePHP convention
|
|
ordering = ['-issue_date']
|
|
|
|
def __str__(self):
|
|
return f"Packing List for Job: {self.job.title}"
|
|
|
|
|
|
# --- Email Related Models (from Vault script context) ---
|
|
|
|
class Email(models.Model):
|
|
# Link to the internal user who sent the email, if known and applicable
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True, # Sender might be external or unknown
|
|
blank=True,
|
|
related_name='sent_emails',
|
|
help_text="Internal sender, if applicable"
|
|
)
|
|
subject = models.CharField(max_length=500) # Allow longer subjects
|
|
udate = models.DateTimeField(db_index=True, verbose_name="Date Received/Sent")
|
|
filename = models.CharField(max_length=255, blank=True, null=True, help_text="Original filename in vault")
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
# HABTM relationships from CakePHP model
|
|
enquiries = models.ManyToManyField(Enquiry, related_name='emails', blank=True, db_table='emails_enquiries') # Explicit join table name
|
|
invoices = models.ManyToManyField(Invoice, related_name='emails', blank=True, db_table='emails_invoices')
|
|
purchase_orders = models.ManyToManyField(PurchaseOrder, related_name='emails', blank=True, db_table='emails_purchase_orders')
|
|
jobs = models.ManyToManyField(Job, related_name='emails', blank=True, db_table='emails_jobs')
|
|
|
|
# email_attachment_count needs manual implementation
|
|
|
|
class Meta:
|
|
db_table = 'emails' # CakePHP convention
|
|
ordering = ['-udate']
|
|
|
|
def __str__(self):
|
|
sender = self.user.get_username() if self.user else 'Unknown Sender' # Use username for internal user
|
|
# If sender is external, you might need to look up the 'From' EmailRecipient
|
|
# For simplicity, this only shows internal sender or 'Unknown'
|
|
return f"Email: {self.subject} (From: {sender})"
|
|
|
|
class EmailRecipient(models.Model):
|
|
email = models.ForeignKey(Email, on_delete=models.CASCADE, related_name='recipients')
|
|
# Link to the Contact model, assuming the vault script creates Contact records for recipients
|
|
contact = models.ForeignKey(
|
|
Contact,
|
|
on_delete=models.CASCADE, # If contact is deleted, remove recipient link?
|
|
related_name='received_emails',
|
|
help_text="Recipient Contact (created_at by vault script if unknown)"
|
|
)
|
|
type = models.CharField(max_length=4, choices=EMAIL_RECIPIENT_TYPES)
|
|
|
|
class Meta:
|
|
db_table = 'email_recipients' # CakePHP convention
|
|
# A contact can receive the same email multiple times if in To and Cc?
|
|
# unique_together = ('email', 'contact', 'type') # Prevent duplicates per email/contact/type
|
|
ordering = ['email', 'type']
|
|
|
|
def __str__(self):
|
|
return f"{self.get_type_display()}: {self.contact.email} (Email ID: {self.email.id})"
|
|
|
|
class EmailAttachment(models.Model):
|
|
email = models.ForeignKey(Email, on_delete=models.CASCADE, related_name='email_attachments') # Changed related_name
|
|
# Store relative path from MEDIA_ROOT, or use a dedicated storage backend
|
|
# Name field stores the relative path + filename generated by vault script
|
|
name = models.CharField(max_length=500, help_text="Path relative to attachment base dir")
|
|
filename = models.CharField(max_length=255, help_text="Original filename from email")
|
|
type = models.CharField(max_length=100) # MIME type
|
|
size = models.PositiveIntegerField()
|
|
is_message_body = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
# Consider adding a FileField if you want Django to manage the files directly
|
|
# file = models.FileField(upload_to='email_attachments/', null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'email_attachments' # CakePHP convention
|
|
ordering = ['email', '-is_message_body', '-size'] # Show body first, then largest
|
|
|
|
def __str__(self):
|
|
return self.filename
|
|
|
|
|
|
# --- Shipment Related Models ---
|
|
|
|
class Shipment(models.Model):
|
|
title = models.CharField(max_length=100, blank=True, null=True, help_text="Optional shipment title/reference")
|
|
freight_forwarder = models.ForeignKey(FreightForwarder, on_delete=models.PROTECT, related_name='shipments')
|
|
freight_service = models.ForeignKey(FreightService, on_delete=models.PROTECT, related_name='shipments')
|
|
# Link to the internal user who created_at the shipment
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_at_shipments',
|
|
help_text="Internal user who created_at this shipment"
|
|
)
|
|
customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='shipments') # Receiving customer
|
|
address = models.ForeignKey(Address, on_delete=models.PROTECT, related_name='shipments') # Delivery address
|
|
airway_bill = models.CharField(max_length=100, blank=True, null=True, verbose_name="Air Waybill / Tracking")
|
|
ship_date = models.DateField(null=True, blank=True)
|
|
expected_delivery_date = models.DateField(null=True, blank=True)
|
|
actual_delivery_date = models.DateField(null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# HABTM relationships
|
|
jobs = models.ManyToManyField(Job, related_name='shipments', blank=True, db_table='shipments_jobs')
|
|
principles = models.ManyToManyField(Principle, related_name='shipments', blank=True, db_table='shipments_principles')
|
|
purchase_orders = models.ManyToManyField(PurchaseOrder, related_name='shipments', blank=True, db_table='shipments_purchase_orders')
|
|
|
|
# Counter cache fields need manual implementation
|
|
# box_count = models.PositiveIntegerField(default=0)
|
|
# shipment_invoice_count = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
db_table = 'shipments' # CakePHP convention
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return f"Shipment {self.id} via {self.freight_forwarder.name} ({self.airway_bill or 'No Tracking'})"
|
|
|
|
class Box(models.Model):
|
|
shipment = models.ForeignKey(Shipment, on_delete=models.CASCADE, related_name='boxes')
|
|
box_number = models.PositiveIntegerField(null=True, blank=True)
|
|
length = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="in cm")
|
|
width = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="in cm")
|
|
height = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="in cm")
|
|
weight = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="in kg")
|
|
|
|
class Meta:
|
|
db_table = 'boxes' # CakePHP convention
|
|
ordering = ['shipment', 'box_number']
|
|
verbose_name_plural = "Boxes"
|
|
|
|
def __str__(self):
|
|
return f"Box {self.box_number or self.id} for Shipment {self.shipment.id}"
|
|
|
|
class ShipmentInvoice(models.Model):
|
|
# Represents invoices related *to* a shipment (e.g., freight costs), not *in* a shipment
|
|
shipment = models.ForeignKey(Shipment, on_delete=models.CASCADE, related_name='related_invoices')
|
|
principle = models.ForeignKey(Principle, on_delete=models.SET_NULL, null=True, blank=True, related_name='shipment_invoices') # Who issued invoice?
|
|
freight_forwarder = models.ForeignKey(FreightForwarder, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices') # Or maybe FF issued it?
|
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, related_name='shipment_invoices')
|
|
invoice_number = models.CharField(max_length=100)
|
|
issue_date = models.DateField()
|
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
|
description = models.CharField(max_length=255, blank=True, null=True)
|
|
|
|
class Meta:
|
|
db_table = 'shipment_invoices' # CakePHP convention
|
|
ordering = ['shipment', '-issue_date']
|
|
|
|
def __str__(self):
|
|
return f"Invoice {self.invoice_number} for Shipment {self.shipment.id}"
|
|
|
|
# --- Authentication / Authorization Related ---
|
|
# Group model is usually handled by django.contrib.auth.models.Group
|
|
# If you need custom group logic, define it here. Otherwise, use Django's built-in.
|
|
|
|
# Example: If you needed a custom Group profile linked to Django's Group
|
|
# from django.contrib.auth.models import Group
|
|
# class GroupProfile(models.Model):
|
|
# group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name='profile')
|
|
# # Add custom fields for the group
|
|
# description = models.TextField(blank=True)
|
|
#
|
|
# class Meta:
|
|
# db_table = 'group_profiles' # Example
|
|
|
|
# Example: Profile model to extend the built-in User
|
|
# class Profile(models.Model):
|
|
# user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
# # Add extra fields for internal users here, e.g.:
|
|
# # phone_extension = models.CharField(max_length=10, blank=True)
|
|
# # department = models.CharField(max_length=50, blank=True)
|
|
#
|
|
# class Meta:
|
|
# db_table = 'profiles' # Example
|
|
#
|
|
# def __str__(self):
|
|
# return f"{self.user.username}'s Profile" |