Coverage for modules\erp\models.py: 91%
145 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:19 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:19 +0800
1from decimal import Decimal
2from django.db import models
3from django.utils import timezone
4from core.models import TenantScopedModel, UUIDModel, TimeStampedModel
5from modules.authentication.models import User
7class Account(TenantScopedModel):
8 """Chart of Accounts for General Ledger."""
9 TYPE_CHOICES = [
10 ('ASSET', 'Asset'),
11 ('LIABILITY', 'Liability'),
12 ('EQUITY', 'Equity'),
13 ('REVENUE', 'Revenue'),
14 ('EXPENSE', 'Expense'),
15 ]
17 code = models.CharField(max_length=32, db_index=True)
18 name = models.CharField(max_length=128)
19 type = models.CharField(max_length=32, choices=TYPE_CHOICES)
20 currency = models.CharField(max_length=3, default='PHP')
21 is_active = models.BooleanField(default=True)
23 class Meta:
24 unique_together = ('tenant_id', 'code')
25 ordering = ['code']
27 def __str__(self):
28 return f"{self.code} - {self.name} ({self.type})"
30class JournalEntry(TenantScopedModel):
31 """General Ledger Journal Entry Header."""
32 STATUS_CHOICES = [
33 ('DRAFT', 'Draft'),
34 ('POSTED', 'Posted'),
35 ('VOIDED', 'Voided'),
36 ]
38 entry_number = models.CharField(max_length=64, db_index=True)
39 date = models.DateField(default=timezone.now)
40 status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='DRAFT')
41 reference_type = models.CharField(max_length=64, blank=True, null=True) # INVOICE, PO, MANUAL
42 reference_id = models.UUIDField(blank=True, null=True)
43 created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
44 posted_at = models.DateTimeField(null=True, blank=True)
46 class Meta:
47 unique_together = ('tenant_id', 'entry_number')
48 ordering = ['-date', '-created_at']
50 def __str__(self):
51 return f"{self.entry_number} ({self.status}) - {self.date}"
53class JournalItem(UUIDModel):
54 """Debit / Credit Line Item inside a Journal Entry."""
55 tenant_id = models.UUIDField(db_index=True)
56 journal_entry = models.ForeignKey(JournalEntry, on_delete=models.CASCADE, related_name='items')
57 account = models.ForeignKey(Account, on_delete=models.RESTRICT, related_name='journal_items')
58 debit_amount = models.DecimalField(max_digits=18, decimal_places=4, default=Decimal('0.0000'))
59 credit_amount = models.DecimalField(max_digits=18, decimal_places=4, default=Decimal('0.0000'))
60 description = models.CharField(max_length=255, blank=True, null=True)
62 def __str__(self):
63 return f"{self.account.code}: Debit {self.debit_amount} / Credit {self.credit_amount}"
65class Product(TenantScopedModel):
66 """Product / Service Catalog Item."""
67 sku = models.CharField(max_length=64, db_index=True)
68 name = models.CharField(max_length=255)
69 description = models.TextField(blank=True, null=True)
70 category = models.CharField(max_length=64, blank=True, null=True)
71 cost_price = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
72 selling_price = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
73 tax_rate = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal('0.00')) # %
74 unit_of_measure = models.CharField(max_length=32, default='pcs')
75 is_active = models.BooleanField(default=True)
76 reorder_level = models.IntegerField(default=10)
78 class Meta:
79 unique_together = ('tenant_id', 'sku')
80 ordering = ['name']
82 @property
83 def current_stock(self) -> Decimal:
84 """Calculate live stock quantity from stock moves."""
85 in_qty = StockMove.objects.filter(
86 product=self,
87 dest_warehouse__isnull=False
88 ).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
90 out_qty = StockMove.objects.filter(
91 product=self,
92 source_warehouse__isnull=False
93 ).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0')
95 return in_qty - out_qty
97 def __str__(self):
98 return f"{self.sku} - {self.name}"
100class Warehouse(TenantScopedModel):
101 """Physical or Virtual Warehouse."""
102 name = models.CharField(max_length=128)
103 code = models.CharField(max_length=32)
104 address = models.TextField(blank=True, null=True)
105 is_active = models.BooleanField(default=True)
107 class Meta:
108 unique_together = ('tenant_id', 'code')
110 def __str__(self):
111 return f"{self.name} ({self.code})"
113class StockMove(TenantScopedModel):
114 """Append-only Stock Ledger Entry."""
115 MOVE_TYPE_CHOICES = [
116 ('INCOMING', 'Incoming'),
117 ('OUTGOING', 'Outgoing'),
118 ('INTERNAL_TRANSFER', 'Internal Transfer'),
119 ('ADJUSTMENT', 'Adjustment'),
120 ]
122 product = models.ForeignKey(Product, on_delete=models.RESTRICT, related_name='stock_moves')
123 source_warehouse = models.ForeignKey(Warehouse, on_delete=models.SET_NULL, null=True, blank=True, related_name='moves_out')
124 dest_warehouse = models.ForeignKey(Warehouse, on_delete=models.SET_NULL, null=True, blank=True, related_name='moves_in')
125 quantity = models.DecimalField(max_digits=14, decimal_places=4)
126 unit_cost = models.DecimalField(max_digits=18, decimal_places=4, default=Decimal('0.0000'))
127 move_type = models.CharField(max_length=32, choices=MOVE_TYPE_CHOICES)
128 reference_doc = models.CharField(max_length=64, blank=True, null=True)
130 class Meta:
131 ordering = ['-created_at']
133 def __str__(self):
134 return f"{self.move_type} {self.quantity} x {self.product.sku}"
136class Invoice(TenantScopedModel):
137 """Customer Invoice."""
138 STATUS_CHOICES = [
139 ('DRAFT', 'Draft'),
140 ('SENT', 'Sent'),
141 ('PAID', 'Paid'),
142 ('PARTIALLY_PAID', 'Partially Paid'),
143 ('VOID', 'Void'),
144 ('OVERDUE', 'Overdue'),
145 ]
147 invoice_number = models.CharField(max_length=64, db_index=True)
148 customer_name = models.CharField(max_length=255)
149 customer_email = models.EmailField(blank=True, null=True)
150 deal_id = models.UUIDField(blank=True, null=True)
151 issue_date = models.DateField(default=timezone.now)
152 due_date = models.DateField()
153 status = models.CharField(max_length=32, choices=STATUS_CHOICES, default='DRAFT')
154 currency = models.CharField(max_length=3, default='PHP')
155 subtotal = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
156 tax_total = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
157 total_amount = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
158 amount_paid = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
159 notes = models.TextField(blank=True, null=True)
160 terms = models.TextField(blank=True, null=True)
162 class Meta:
163 unique_together = ('tenant_id', 'invoice_number')
164 ordering = ['-issue_date', '-created_at']
166 @property
167 def balance_due(self) -> Decimal:
168 return self.total_amount - self.amount_paid
170 def __str__(self):
171 return f"INV-{self.invoice_number} ({self.customer_name}) - ${self.total_amount}"
173class InvoiceLine(UUIDModel):
174 """Individual line item in an invoice."""
175 tenant_id = models.UUIDField(db_index=True)
176 invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='lines')
177 product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True)
178 description = models.CharField(max_length=255)
179 quantity = models.DecimalField(max_digits=14, decimal_places=2, default=Decimal('1.00'))
180 unit_price = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
181 tax_rate = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal('0.00'))
182 subtotal = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
183 tax_amount = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
184 total = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
186 def __str__(self):
187 return f"{self.description} ({self.quantity} x {self.unit_price})"
190class Shipment(TenantScopedModel):
191 """Customs brokerage shipment / job."""
192 SHIPMENT_TYPE_CHOICES = [
193 ('IMPORT', 'Import'),
194 ('EXPORT', 'Export'),
195 ('TRANSIT', 'Transit'),
196 ]
197 STATUS_CHOICES = [
198 ('DRAFT', 'Draft'),
199 ('DOCUMENTS_PENDING', 'Documents Pending'),
200 ('CUSTOMS_CLEARED', 'Customs Cleared'),
201 ('IN_TRANSIT', 'In Transit'),
202 ('DELIVERED', 'Delivered'),
203 ('ON_HOLD', 'On Hold'),
204 ('CANCELLED', 'Cancelled'),
205 ]
207 reference_number = models.CharField(max_length=32)
208 customer_name = models.CharField(max_length=255)
209 customer_email = models.EmailField(blank=True, null=True)
210 shipment_type = models.CharField(max_length=16, choices=SHIPMENT_TYPE_CHOICES, default='IMPORT')
211 status = models.CharField(max_length=32, choices=STATUS_CHOICES, default='DRAFT')
212 origin = models.CharField(max_length=255)
213 destination = models.CharField(max_length=255)
214 carrier = models.CharField(max_length=255, blank=True, null=True)
215 tracking_number = models.CharField(max_length=128, blank=True, null=True)
216 bill_of_lading = models.CharField(max_length=128, blank=True, null=True)
217 customs_entry_number = models.CharField(max_length=128, blank=True, null=True)
218 estimated_arrival = models.DateField(null=True, blank=True)
219 declared_value = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
220 duty_amount = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
221 freight_amount = models.DecimalField(max_digits=18, decimal_places=2, default=Decimal('0.00'))
222 currency = models.CharField(max_length=3, default='PHP')
223 notes = models.TextField(blank=True, null=True)
225 class Meta:
226 unique_together = ('tenant_id', 'reference_number')
227 ordering = ['-created_at']
229 def __str__(self):
230 return f"{self.reference_number} - {self.customer_name}"