Coverage for modules\hr\api.py: 34%

204 statements  

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

1from decimal import Decimal 

2from typing import List 

3from uuid import UUID 

4from ninja import Router 

5from core.auth import JWTAuth 

6from core.exceptions import BadRequestException, NotFoundException 

7from .models import EmployeeProfile, LeaveRequest, OvertimeRequest, Claim, PayrollRun, PayrollItem, TaxDocument, PerformanceReview, Payslip, Month13Bonus 

8from .schemas import ( 

9 EmployeeIn, EmployeeOut, LeaveIn, LeaveOut, LeaveStatusIn, 

10 OvertimeIn, OvertimeOut, ClaimIn, ClaimOut, ClaimStatusIn, 

11 PayrollRunIn, PayrollRunOut, PayslipOut, PayslipIn, PayslipSendIn, 

12 Month13BonusOut, Month13ItemOut, Month13RunIn, 

13) 

14from .services import ( 

15 require_hr, calculate_withholding_tax, generate_payslips_for_run, 

16 send_payslip_email, calculate_13th_month, release_13th_month, 

17) 

18from modules.authentication.models import User 

19from django.db import transaction 

20 

21router = Router(tags=['Human Resources']) 

22 

23 

24def employee_out(profile, include_salary=False): 

25 return EmployeeOut(id=profile.id, user_id=profile.user_id, employee_number=profile.employee_number, name=profile.user.full_name, email=profile.user.email, job_title=profile.job_title, employment_status=profile.employment_status, salary_monthly=profile.salary_monthly if include_salary else None) 

26 

27 

28def can_view_salary(request): 

29 return request.user.is_superuser or request.user.memberships.filter( 

30 organization_id=request.user.current_organization_id, 

31 role='HR', 

32 ).exists() 

33 

34 

35def leave_out(item): 

36 return LeaveOut(id=item.id, profile_id=item.profile_id, leave_type=item.leave_type, start_date=item.start_date, end_date=item.end_date, reason=item.reason, status=item.status, employee_name=item.profile.user.full_name, created_at=item.created_at) 

37 

38 

39def claim_out(item): 

40 return ClaimOut(id=item.id, profile_id=item.profile_id, category=item.category, amount=item.amount, claim_date=item.claim_date, description=item.description, status=item.status, employee_name=item.profile.user.full_name, created_at=item.created_at) 

41 

42 

43def run_out(run): 

44 return PayrollRunOut(id=run.id, period_start=run.period_start, period_end=run.period_end, status=run.status, total_gross=run.total_gross, total_tax=run.total_tax, total_net=run.total_net, item_count=run.items.count(), sss_contribution=run.sss_contribution, philhealth_contribution=run.philhealth_contribution, pagibig_contribution=run.pagibig_contribution) 

45 

46 

47def _payslip_out(p): 

48 from .schemas import PayslipEarnings, PayslipDeductions 

49 return PayslipOut( 

50 id=p.id, 

51 payroll_item_id=p.payroll_item_id, 

52 employee_name=p.employee_name, 

53 employee_number=p.employee_number, 

54 job_title=p.job_title, 

55 period_start=p.period_start, 

56 period_end=p.period_end, 

57 earnings=PayslipEarnings( 

58 basic_salary=p.basic_salary, 

59 overtime_pay=p.overtime_pay, 

60 allowances=p.allowances, 

61 other_earnings=p.other_earnings, 

62 gross_pay=p.gross_pay, 

63 ), 

64 deductions=PayslipDeductions( 

65 sss=p.sss_deduction, 

66 philhealth=p.philhealth_deduction, 

67 pagibig=p.pagibig_deduction, 

68 withholding_tax=p.withholding_tax, 

69 other_deductions=p.other_deductions, 

70 total_deductions=p.total_deductions, 

71 ), 

72 net_pay=p.net_pay, 

73 status=p.status, 

74 generated_at=p.generated_at, 

75 ) 

76 

77 

78@router.get('/summary', auth=JWTAuth()) 

79def summary(request): 

80 tenant_id = require_hr(request) 

81 profiles = EmployeeProfile.objects.filter(tenant_id=tenant_id) 

82 return {'employees': profiles.count(), 'pending_leave': LeaveRequest.objects.filter(tenant_id=tenant_id, status='PENDING').count(), 'pending_claims': Claim.objects.filter(tenant_id=tenant_id, status='PENDING').count(), 'payroll_runs': PayrollRun.objects.filter(tenant_id=tenant_id).count(), 'tax_documents': TaxDocument.objects.filter(tenant_id=tenant_id).count(), 'performance_reviews': PerformanceReview.objects.filter(tenant_id=tenant_id).count()} 

83 

84 

85@router.get('/employees', response=List[EmployeeOut], auth=JWTAuth()) 

86def employees(request): 

87 tenant_id = require_hr(request) 

88 return [employee_out(profile, can_view_salary(request)) for profile in EmployeeProfile.objects.filter(tenant_id=tenant_id).select_related('user')] 

89 

90 

91@router.post('/employees', response={201: EmployeeOut}, auth=JWTAuth()) 

92@transaction.atomic 

93def employee_create(request, payload: EmployeeIn): 

94 tenant_id = require_hr(request) 

95 if EmployeeProfile.objects.filter(tenant_id=tenant_id, employee_number=payload.employee_number).exists(): 

96 raise BadRequestException('Employee number already exists.') 

97 user, created = User.objects.get_or_create( 

98 email=payload.email.lower(), 

99 defaults={ 

100 'first_name': payload.first_name, 

101 'last_name': payload.last_name, 

102 'current_organization_id': tenant_id, 

103 'is_active': True, 

104 }, 

105 ) 

106 if not created: 

107 raise BadRequestException('A user with this email already exists.') 

108 user.set_unusable_password() 

109 user.save(update_fields=['password', 'updated_at']) 

110 profile = EmployeeProfile.objects.create( 

111 tenant_id=tenant_id, 

112 user=user, 

113 employee_number=payload.employee_number, 

114 hire_date=payload.hire_date, 

115 job_title=payload.job_title, 

116 salary_monthly=payload.salary_monthly, 

117 ) 

118 return 201, employee_out(profile, True) 

119 

120 

121@router.get('/leave', response=List[LeaveOut], auth=JWTAuth()) 

122def leave_list(request): 

123 return [leave_out(item) for item in LeaveRequest.objects.filter(tenant_id=require_hr(request)).select_related('profile__user')[:100]] 

124 

125 

126@router.post('/leave', response={201: LeaveOut}, auth=JWTAuth()) 

127def leave_create(request, payload: LeaveIn): 

128 tenant_id = require_hr(request) 

129 if payload.end_date < payload.start_date: 

130 raise BadRequestException('Leave end date must be on or after the start date.') 

131 profile = EmployeeProfile.objects.filter(id=payload.profile_id, tenant_id=tenant_id).select_related('user').first() 

132 if not profile: 

133 raise NotFoundException('Employee profile not found.') 

134 item = LeaveRequest.objects.create(tenant_id=tenant_id, **payload.model_dump()) 

135 return 201, leave_out(item) 

136 

137 

138@router.patch('/leave/{item_id}', response=LeaveOut, auth=JWTAuth()) 

139def leave_update_status(request, item_id: UUID, payload: LeaveStatusIn): 

140 tenant_id = require_hr(request) 

141 item = LeaveRequest.objects.filter(id=item_id, tenant_id=tenant_id).first() 

142 if not item: 

143 raise NotFoundException('Leave request not found.') 

144 if payload.status not in ('PENDING', 'APPROVED', 'REJECTED'): 

145 raise BadRequestException('Status must be PENDING, APPROVED, or REJECTED.') 

146 item.status = payload.status 

147 item.reviewed_by = request.user 

148 item.save() 

149 return leave_out(item) 

150 

151 

152@router.get('/claims', response=List[ClaimOut], auth=JWTAuth()) 

153def claims_list(request): 

154 return [claim_out(item) for item in Claim.objects.filter(tenant_id=require_hr(request)).select_related('profile__user')[:100]] 

155 

156 

157@router.post('/claims', response={201: ClaimOut}, auth=JWTAuth()) 

158def claim_create(request, payload: ClaimIn): 

159 tenant_id = require_hr(request) 

160 profile = EmployeeProfile.objects.filter(id=payload.profile_id, tenant_id=tenant_id).first() 

161 if not profile: 

162 raise NotFoundException('Employee profile not found.') 

163 item = Claim.objects.create(tenant_id=tenant_id, **payload.model_dump()) 

164 return 201, claim_out(item) 

165 

166 

167@router.patch('/claims/{item_id}', response=ClaimOut, auth=JWTAuth()) 

168def claim_update_status(request, item_id: UUID, payload: ClaimStatusIn): 

169 tenant_id = require_hr(request) 

170 item = Claim.objects.filter(id=item_id, tenant_id=tenant_id).first() 

171 if not item: 

172 raise NotFoundException('Claim request not found.') 

173 if payload.status not in ('PENDING', 'APPROVED', 'REJECTED'): 

174 raise BadRequestException('Status must be PENDING, APPROVED, or REJECTED.') 

175 item.status = payload.status 

176 item.reviewed_by = request.user 

177 item.save() 

178 return claim_out(item) 

179 

180 

181@router.get('/overtime', response=List[OvertimeOut], auth=JWTAuth()) 

182def overtime_list(request): 

183 return [overtime_out(item) for item in OvertimeRequest.objects.filter(tenant_id=require_hr(request)).select_related('profile__user')[:100]] 

184 

185 

186@router.post('/overtime', response={201: OvertimeOut}, auth=JWTAuth()) 

187def overtime_create(request, payload: OvertimeIn): 

188 tenant_id = require_hr(request) 

189 profile = EmployeeProfile.objects.filter(id=payload.profile_id, tenant_id=tenant_id).select_related('user').first() 

190 if not profile: 

191 raise NotFoundException('Employee profile not found.') 

192 item = OvertimeRequest.objects.create(tenant_id=tenant_id, **payload.model_dump()) 

193 return 201, overtime_out(item) 

194 

195 

196@router.get('/claims', response=List[ClaimOut], auth=JWTAuth()) 

197def claims(request): 

198 return [claim_out(item) for item in Claim.objects.filter(tenant_id=require_hr(request)).select_related('profile__user')[:100]] 

199 

200 

201@router.post('/claims', response={201: ClaimOut}, auth=JWTAuth()) 

202def claim_create(request, payload: ClaimIn): 

203 tenant_id = require_hr(request) 

204 profile = EmployeeProfile.objects.filter(id=payload.profile_id, tenant_id=tenant_id).select_related('user').first() 

205 if not profile: 

206 raise NotFoundException('Employee profile not found.') 

207 item = Claim.objects.create(tenant_id=tenant_id, **payload.model_dump()) 

208 return 201, claim_out(item) 

209 

210 

211@router.get('/payroll/runs', response=List[PayrollRunOut], auth=JWTAuth()) 

212def payroll_runs(request): 

213 return [run_out(run) for run in PayrollRun.objects.filter(tenant_id=require_hr(request))] 

214 

215 

216@router.get('/tax-documents', auth=JWTAuth()) 

217def tax_documents(request): 

218 return list(TaxDocument.objects.filter(tenant_id=require_hr(request)).values('id', 'profile_id', 'tax_year', 'document_type', 'status', 'annual_gross', 'annual_tax')) 

219 

220 

221@router.get('/performance-reviews', auth=JWTAuth()) 

222def performance_reviews(request): 

223 return list(PerformanceReview.objects.filter(tenant_id=require_hr(request)).values('id', 'profile_id', 'review_period', 'rating', 'status')) 

224 

225 

226@router.post('/payroll/runs', response={201: PayrollRunOut}, auth=JWTAuth()) 

227def payroll_run_create(request, payload: PayrollRunIn): 

228 tenant_id = require_hr(request) 

229 if payload.period_end < payload.period_start: 

230 raise BadRequestException('Payroll period end must be on or after the start date.') 

231 profiles = EmployeeProfile.objects.filter(tenant_id=tenant_id) 

232 if payload.profile_ids: 

233 profiles = profiles.filter(id__in=payload.profile_ids) 

234 run = PayrollRun.objects.create(tenant_id=tenant_id, period_start=payload.period_start, period_end=payload.period_end) 

235 for profile in profiles: 

236 rates = payload.pay_rates or [profile.salary_monthly] 

237 gross = sum(rates, Decimal('0')) 

238 tax = calculate_withholding_tax(gross * 12) / 12 

239 PayrollItem.objects.create(tenant_id=tenant_id, run=run, profile=profile, pay_rates=[str(rate) for rate in rates], gross_pay=gross, taxable_income=gross, withholding_tax=tax, net_pay=gross - tax) 

240 # Generate payslips for all items 

241 run = generate_payslips_for_run(run) 

242 return 201, run_out(run) 

243 

244 

245@router.get('/payslips', response=List[PayslipOut], auth=JWTAuth()) 

246def payslip_list(request): 

247 """List all payslips for the tenant.""" 

248 tenant_id = require_hr(request) 

249 payslips = Payslip.objects.filter(tenant_id=tenant_id).select_related('payroll_item__profile__user', 'payroll_item__run')[:100] 

250 return [_payslip_out(p) for p in payslips] 

251 

252 

253@router.get('/payslips/{payroll_item_id}', response=PayslipOut, auth=JWTAuth()) 

254def payslip_get(request, payroll_item_id: UUID): 

255 """Get a specific payslip by payroll item ID.""" 

256 tenant_id = require_hr(request) 

257 payslip = Payslip.objects.filter(payroll_item_id=payroll_item_id, tenant_id=tenant_id).first() 

258 if not payslip: 

259 raise NotFoundException('Payslip not found.') 

260 return _payslip_out(payslip) 

261 

262 

263@router.post('/payslips/generate/{run_id}', response={201: List[PayslipOut]}, auth=JWTAuth()) 

264def payslip_generate(request, run_id: UUID): 

265 """Regenerate payslips for a payroll run.""" 

266 tenant_id = require_hr(request) 

267 run = PayrollRun.objects.filter(id=run_id, tenant_id=tenant_id).first() 

268 if not run: 

269 raise NotFoundException('Payroll run not found.') 

270 run = generate_payslips_for_run(run) 

271 payslips = Payslip.objects.filter(tenant_id=tenant_id, payroll_item__run=run).select_related('payroll_item__profile__user') 

272 return 201, [_payslip_out(p) for p in payslips] 

273 

274 

275@router.post('/payslips/{payslip_id}/send', auth=JWTAuth()) 

276def payslip_send(request, payslip_id: UUID, data: PayslipSendIn): 

277 """Send a payslip to an employee via email.""" 

278 tenant_id = require_hr(request) 

279 payslip = Payslip.objects.filter(id=payslip_id, tenant_id=tenant_id).first() 

280 if not payslip: 

281 raise NotFoundException('Payslip not found.') 

282 success, message = send_payslip_email(payslip, data.employee_email, data.subject, data.message) 

283 if not success: 

284 raise BadRequestException(message) 

285 return {'success': True, 'message': message} 

286 

287 

288# ── 13th Month Bonus Endpoints ──────────────────────────────────────────────── 

289 

290def _bonus_out(bonus): 

291 items = [ 

292 Month13ItemOut( 

293 id=i.id, 

294 profile_id=i.profile_id, 

295 employee_name=i.employee_name, 

296 employee_number=i.employee_number, 

297 months_employed=i.months_employed, 

298 basic_salary_total=i.basic_salary_total, 

299 bonus_amount=i.bonus_amount, 

300 status=i.status, 

301 released_at=i.released_at, 

302 ) 

303 for i in bonus.items.all() 

304 ] 

305 return Month13BonusOut( 

306 id=bonus.id, 

307 year=bonus.year, 

308 status=bonus.status, 

309 total_gross=bonus.total_gross, 

310 total_net=bonus.total_net, 

311 item_count=bonus.item_count, 

312 notes=bonus.notes or '', 

313 items=items, 

314 created_at=bonus.created_at, 

315 ) 

316 

317 

318@router.get('/13th-month', response=List[Month13BonusOut], auth=JWTAuth()) 

319def month13_list(request): 

320 """List all 13th month bonus runs for the tenant.""" 

321 tenant_id = require_hr(request) 

322 bonuses = Month13Bonus.objects.filter(tenant_id=tenant_id).order_by('-year') 

323 return [_bonus_out(b) for b in bonuses] 

324 

325 

326@router.post('/13th-month', response={201: Month13BonusOut}, auth=JWTAuth()) 

327def month13_generate(request, payload: Month13RunIn): 

328 """Calculate 13th month bonus for a given year (or current year).""" 

329 tenant_id = require_hr(request) 

330 bonus = calculate_13th_month(tenant_id, payload.year, payload.profile_ids) 

331 if payload.notes: 

332 bonus.notes = payload.notes 

333 bonus.save(update_fields=['notes']) 

334 return 201, _bonus_out(bonus) 

335 

336 

337@router.get('/13th-month/{bonus_id}', response=Month13BonusOut, auth=JWTAuth()) 

338def month13_get(request, bonus_id: UUID): 

339 """Get a specific 13th month bonus run.""" 

340 tenant_id = require_hr(request) 

341 bonus = Month13Bonus.objects.filter(id=bonus_id, tenant_id=tenant_id).first() 

342 if not bonus: 

343 raise NotFoundException('13th month bonus not found.') 

344 return _bonus_out(bonus) 

345 

346 

347@router.post('/13th-month/{bonus_id}/release', response=Month13BonusOut, auth=JWTAuth()) 

348def month13_release(request, bonus_id: UUID): 

349 """Release (mark as paid) all pending items in a 13th month run.""" 

350 tenant_id = require_hr(request) 

351 bonus = Month13Bonus.objects.filter(id=bonus_id, tenant_id=tenant_id).first() 

352 if not bonus: 

353 raise NotFoundException('13th month bonus not found.') 

354 bonus = release_13th_month(bonus) 

355 return _bonus_out(bonus)