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

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 

6 

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 ] 

16 

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) 

22 

23 class Meta: 

24 unique_together = ('tenant_id', 'code') 

25 ordering = ['code'] 

26 

27 def __str__(self): 

28 return f"{self.code} - {self.name} ({self.type})" 

29 

30class JournalEntry(TenantScopedModel): 

31 """General Ledger Journal Entry Header.""" 

32 STATUS_CHOICES = [ 

33 ('DRAFT', 'Draft'), 

34 ('POSTED', 'Posted'), 

35 ('VOIDED', 'Voided'), 

36 ] 

37 

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) 

45 

46 class Meta: 

47 unique_together = ('tenant_id', 'entry_number') 

48 ordering = ['-date', '-created_at'] 

49 

50 def __str__(self): 

51 return f"{self.entry_number} ({self.status}) - {self.date}" 

52 

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) 

61 

62 def __str__(self): 

63 return f"{self.account.code}: Debit {self.debit_amount} / Credit {self.credit_amount}" 

64 

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) 

77 

78 class Meta: 

79 unique_together = ('tenant_id', 'sku') 

80 ordering = ['name'] 

81 

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') 

89 

90 out_qty = StockMove.objects.filter( 

91 product=self, 

92 source_warehouse__isnull=False 

93 ).aggregate(models.Sum('quantity'))['quantity__sum'] or Decimal('0') 

94 

95 return in_qty - out_qty 

96 

97 def __str__(self): 

98 return f"{self.sku} - {self.name}" 

99 

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) 

106 

107 class Meta: 

108 unique_together = ('tenant_id', 'code') 

109 

110 def __str__(self): 

111 return f"{self.name} ({self.code})" 

112 

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 ] 

121 

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) 

129 

130 class Meta: 

131 ordering = ['-created_at'] 

132 

133 def __str__(self): 

134 return f"{self.move_type} {self.quantity} x {self.product.sku}" 

135 

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 ] 

146 

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) 

161 

162 class Meta: 

163 unique_together = ('tenant_id', 'invoice_number') 

164 ordering = ['-issue_date', '-created_at'] 

165 

166 @property 

167 def balance_due(self) -> Decimal: 

168 return self.total_amount - self.amount_paid 

169 

170 def __str__(self): 

171 return f"INV-{self.invoice_number} ({self.customer_name}) - ${self.total_amount}" 

172 

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')) 

185 

186 def __str__(self): 

187 return f"{self.description} ({self.quantity} x {self.unit_price})" 

188 

189 

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 ] 

206 

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) 

224 

225 class Meta: 

226 unique_together = ('tenant_id', 'reference_number') 

227 ordering = ['-created_at'] 

228 

229 def __str__(self): 

230 return f"{self.reference_number} - {self.customer_name}"