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
« 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
21router = Router(tags=['Human Resources'])
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)
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()
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)
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)
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)
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 )
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()}
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')]
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)
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]]
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)
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)
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]]
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)
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)
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]]
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)
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]]
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)
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))]
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'))
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'))
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)
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]
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)
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]
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}
288# ── 13th Month Bonus Endpoints ────────────────────────────────────────────────
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 )
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]
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)
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)
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)