cmc-sales/cmc-django/cmcsales/cmc/models.py
Karl Cordes 6ad0e74ad6 Add instructions to README for docker compose
add cmc-django
add finley to userpasswd
2025-06-03 07:28:32 +10:00

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"