Coverage for modules\erp\services.py: 62%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 17:01 +0800

1import uuid 

2from decimal import Decimal 

3from datetime import date, timedelta 

4from django.db import transaction 

5from django.utils import timezone 

6from core.email_service import send_invoice_email 

7from core.exceptions import NotFoundException, BadRequestException 

8from .models import Account, JournalEntry, JournalItem, Product, Warehouse, StockMove, Invoice, InvoiceLine, Shipment 

9 

10class ERPService: 

11 @staticmethod 

12 @transaction.atomic 

13 def create_shipment(tenant_id, data): 

14 count = Shipment.objects.filter(tenant_id=tenant_id).count() + 1 

15 return Shipment.objects.create( 

16 tenant_id=tenant_id, 

17 reference_number=f"SHP-{count:05d}", 

18 **data.dict() 

19 ) 

20 

21 @staticmethod 

22 def get_or_create_chart_of_accounts(tenant_id): 

23 """Seed default chart of accounts if empty.""" 

24 if not Account.objects.filter(tenant_id=tenant_id).exists(): 

25 default_accounts = [ 

26 ("1010", "Cash & Bank", "ASSET"), 

27 ("1020", "Accounts Receivable", "ASSET"), 

28 ("1030", "Inventory", "ASSET"), 

29 ("2010", "Accounts Payable", "LIABILITY"), 

30 ("2020", "Sales Tax Payable", "LIABILITY"), 

31 ("3010", "Retained Earnings", "EQUITY"), 

32 ("4010", "Sales Revenue", "REVENUE"), 

33 ("5010", "Cost of Goods Sold", "EXPENSE"), 

34 ("5020", "General Operating Expense", "EXPENSE"), 

35 ] 

36 for code, name, acct_type in default_accounts: 

37 Account.objects.create( 

38 tenant_id=tenant_id, 

39 code=code, 

40 name=name, 

41 type=acct_type, 

42 currency='PHP' 

43 ) 

44 

45 @staticmethod 

46 @transaction.atomic 

47 def create_invoice(tenant_id, data): 

48 # Generate sequential invoice number 

49 count = Invoice.objects.filter(tenant_id=tenant_id).count() + 1 

50 invoice_number = f"{count:05d}" 

51 

52 subtotal = Decimal('0.00') 

53 tax_total = Decimal('0.00') 

54 lines_data = [] 

55 

56 for line in data.lines: 

57 line_subtotal = (line.quantity * line.unit_price).quantize(Decimal('0.01')) 

58 line_tax = (line_subtotal * (line.tax_rate / Decimal('100.00'))).quantize(Decimal('0.01')) 

59 line_total = line_subtotal + line_tax 

60 

61 subtotal += line_subtotal 

62 tax_total += line_tax 

63 lines_data.append({ 

64 'product_id': line.product_id, 

65 'description': line.description, 

66 'quantity': line.quantity, 

67 'unit_price': line.unit_price, 

68 'tax_rate': line.tax_rate, 

69 'subtotal': line_subtotal, 

70 'tax_amount': line_tax, 

71 'total': line_total, 

72 }) 

73 

74 total_amount = subtotal + tax_total 

75 

76 invoice = Invoice.objects.create( 

77 tenant_id=tenant_id, 

78 invoice_number=invoice_number, 

79 customer_name=data.customer_name, 

80 customer_email=data.customer_email, 

81 deal_id=data.deal_id, 

82 issue_date=data.issue_date or timezone.now().date(), 

83 due_date=data.due_date, 

84 currency=data.currency or 'PHP', 

85 subtotal=subtotal, 

86 tax_total=tax_total, 

87 total_amount=total_amount, 

88 notes=data.notes, 

89 terms=data.terms, 

90 status='SENT', 

91 ) 

92 

93 for ld in lines_data: 

94 InvoiceLine.objects.create( 

95 tenant_id=tenant_id, 

96 invoice=invoice, 

97 **ld 

98 ) 

99 

100 # Create Ledger Journal Entry (Double-Entry: Debit AR / Credit Revenue & Tax) 

101 ERPService.post_invoice_journal(tenant_id, invoice) 

102 

103 if invoice.customer_email: 

104 transaction.on_commit( 

105 lambda: send_invoice_email(invoice, invoice.customer_email) 

106 ) 

107 

108 return invoice 

109 

110 @staticmethod 

111 def post_invoice_journal(tenant_id, invoice: Invoice): 

112 """Create balanced double-entry journal entry for invoice.""" 

113 ERPService.get_or_create_chart_of_accounts(tenant_id) 

114 ar_acct = Account.objects.filter(tenant_id=tenant_id, code='1020').first() 

115 rev_acct = Account.objects.filter(tenant_id=tenant_id, code='4010').first() 

116 tax_acct = Account.objects.filter(tenant_id=tenant_id, code='2020').first() 

117 

118 if ar_acct and rev_acct: 

119 entry_number = f"JE-INV-{invoice.invoice_number}" 

120 je = JournalEntry.objects.create( 

121 tenant_id=tenant_id, 

122 entry_number=entry_number, 

123 date=invoice.issue_date, 

124 status='POSTED', 

125 reference_type='INVOICE', 

126 reference_id=invoice.id, 

127 posted_at=timezone.now() 

128 ) 

129 

130 # Debit Accounts Receivable 

131 JournalItem.objects.create( 

132 tenant_id=tenant_id, 

133 journal_entry=je, 

134 account=ar_acct, 

135 debit_amount=invoice.total_amount, 

136 credit_amount=Decimal('0.00'), 

137 description=f"Invoice #{invoice.invoice_number} AR" 

138 ) 

139 

140 # Credit Revenue 

141 JournalItem.objects.create( 

142 tenant_id=tenant_id, 

143 journal_entry=je, 

144 account=rev_acct, 

145 debit_amount=Decimal('0.00'), 

146 credit_amount=invoice.subtotal, 

147 description=f"Invoice #{invoice.invoice_number} Sales Revenue" 

148 ) 

149 

150 # Credit Tax (if any) 

151 if invoice.tax_total > 0 and tax_acct: 

152 JournalItem.objects.create( 

153 tenant_id=tenant_id, 

154 journal_entry=je, 

155 account=tax_acct, 

156 debit_amount=Decimal('0.00'), 

157 credit_amount=invoice.tax_total, 

158 description=f"Invoice #{invoice.invoice_number} Sales Tax" 

159 ) 

160 

161 @staticmethod 

162 @transaction.atomic 

163 def record_payment(tenant_id, invoice_id, amount: Decimal): 

164 invoice = Invoice.objects.filter(id=invoice_id, tenant_id=tenant_id).first() 

165 if not invoice: 

166 raise NotFoundException("Invoice not found.") 

167 

168 invoice.amount_paid += amount 

169 if invoice.amount_paid >= invoice.total_amount: 

170 invoice.status = 'PAID' 

171 else: 

172 invoice.status = 'PARTIALLY_PAID' 

173 invoice.save() 

174 

175 # Double-entry: Debit Cash (1010) / Credit Accounts Receivable (1020) 

176 cash_acct = Account.objects.filter(tenant_id=tenant_id, code='1010').first() 

177 ar_acct = Account.objects.filter(tenant_id=tenant_id, code='1020').first() 

178 

179 if cash_acct and ar_acct: 

180 count = JournalEntry.objects.filter(tenant_id=tenant_id).count() + 1 

181 je = JournalEntry.objects.create( 

182 tenant_id=tenant_id, 

183 entry_number=f"JE-PAY-{count:05d}", 

184 date=timezone.now().date(), 

185 status='POSTED', 

186 reference_type='PAYMENT', 

187 reference_id=invoice.id, 

188 posted_at=timezone.now() 

189 ) 

190 JournalItem.objects.create( 

191 tenant_id=tenant_id, 

192 journal_entry=je, 

193 account=cash_acct, 

194 debit_amount=amount, 

195 credit_amount=Decimal('0.00'), 

196 description=f"Payment for Invoice #{invoice.invoice_number}" 

197 ) 

198 JournalItem.objects.create( 

199 tenant_id=tenant_id, 

200 journal_entry=je, 

201 account=ar_acct, 

202 debit_amount=Decimal('0.00'), 

203 credit_amount=amount, 

204 description=f"Payment applied to Invoice #{invoice.invoice_number}" 

205 ) 

206 

207 return invoice 

208 

209 @staticmethod 

210 def create_invoice_from_deal(deal): 

211 """Triggered automatically when a CRM deal is won.""" 

212 tenant_id = deal.tenant_id 

213 customer_name = deal.company.name if deal.company else (deal.contact.full_name if deal.contact else deal.title) 

214 customer_email = deal.contact.email if deal.contact else None 

215 

216 class DealInvoicePayload: 

217 pass 

218 

219 data = DealInvoicePayload() 

220 data.customer_name = customer_name 

221 data.customer_email = customer_email 

222 data.deal_id = deal.id 

223 data.issue_date = timezone.now().date() 

224 data.due_date = timezone.now().date() + timedelta(days=30) 

225 data.currency = deal.currency 

226 data.notes = f"Generated from Won Deal: {deal.title}" 

227 data.terms = "Net 30" 

228 

229 class LinePayload: 

230 product_id = None 

231 description = f"Services for deal: {deal.title}" 

232 quantity = Decimal('1.00') 

233 unit_price = deal.value 

234 tax_rate = Decimal('0.00') 

235 

236 data.lines = [LinePayload()] 

237 return ERPService.create_invoice(tenant_id, data)