Michael-Antony commited on
Commit
ca7fde4
·
1 Parent(s): 311caf1

feat: Implement check-out API with total working time calculation

Browse files

- Add CheckOutRequest and CheckOutResponse schemas
- Implement create_checkout service method with edge case handling
- Add POST /attendance/check-out endpoint
- Calculate total working minutes automatically
- Handle edge cases: no check-in found, already checked out, GPS disabled
- Validate check-out time is after check-in time
- Update attendance record with check-out details

app/tracker/attendance/constants.py CHANGED
@@ -11,6 +11,9 @@ ERROR_GPS_DISABLED = "GPS tracking is disabled for this employee"
11
  ERROR_LOCATION_REQUIRED = "Location coordinates are required"
12
  ERROR_EMPLOYEE_NOT_FOUND = "Employee not found"
13
  ERROR_INVALID_COORDINATES = "Invalid GPS coordinates"
 
 
14
 
15
  # Success messages
16
  SUCCESS_CHECKIN = "Check-in successful"
 
 
11
  ERROR_LOCATION_REQUIRED = "Location coordinates are required"
12
  ERROR_EMPLOYEE_NOT_FOUND = "Employee not found"
13
  ERROR_INVALID_COORDINATES = "Invalid GPS coordinates"
14
+ ERROR_NO_CHECKIN_FOUND = "No check-in found for today"
15
+ ERROR_ALREADY_CHECKED_OUT = "Already checked out for today"
16
 
17
  # Success messages
18
  SUCCESS_CHECKIN = "Check-in successful"
19
+ SUCCESS_CHECKOUT = "Check-out successful"
app/tracker/attendance/router.py CHANGED
@@ -6,7 +6,7 @@ from app.core.logging import get_logger
6
  from app.dependencies.auth import get_current_user, TokenUser
7
  from app.nosql import get_database
8
  from app.tracker.attendance.service import AttendanceService, get_attendance_service
9
- from app.tracker.attendance.schemas import CheckInRequest, CheckInResponse, ErrorResponse
10
 
11
  logger = get_logger(__name__)
12
 
@@ -136,3 +136,116 @@ async def attendance_health():
136
  "module": "attendance",
137
  "version": "1.0.0"
138
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from app.dependencies.auth import get_current_user, TokenUser
7
  from app.nosql import get_database
8
  from app.tracker.attendance.service import AttendanceService, get_attendance_service
9
+ from app.tracker.attendance.schemas import CheckInRequest, CheckInResponse, CheckOutRequest, CheckOutResponse, ErrorResponse
10
 
11
  logger = get_logger(__name__)
12
 
 
136
  "module": "attendance",
137
  "version": "1.0.0"
138
  }
139
+
140
+
141
+ @router.post(
142
+ "/check-out",
143
+ response_model=CheckOutResponse,
144
+ status_code=status.HTTP_200_OK,
145
+ responses={
146
+ 200: {"description": "Check-out successful"},
147
+ 400: {"model": ErrorResponse, "description": "Bad request - no check-in found or already checked out"},
148
+ 401: {"description": "Unauthorized"},
149
+ 422: {"description": "Validation error"},
150
+ 500: {"model": ErrorResponse, "description": "Internal server error"}
151
+ },
152
+ summary="Employee Check-Out",
153
+ description="""
154
+ Mark the end of an employee's working day and calculate total working time.
155
+
156
+ **Rules:**
157
+ - Allowed only after successful check-in
158
+ - Location coordinates are mandatory
159
+ - Auto-calculates total working hours in minutes
160
+ - GPS tracking must be enabled for the employee
161
+
162
+ **Edge Cases:**
163
+ - No check-in found → 400 error
164
+ - Already checked out → 400 error
165
+ - GPS disabled → 400 error
166
+ - Check-out time before check-in time → 400 error
167
+
168
+ **Data Storage:**
169
+ - Updates the attendance record with check-out timestamp, coordinates, and total working minutes
170
+ - Table: trans.scm_attendance
171
+ """
172
+ )
173
+ async def check_out(
174
+ payload: CheckOutRequest,
175
+ current_user: TokenUser = Depends(get_current_user)
176
+ ) -> CheckOutResponse:
177
+ """
178
+ Create a check-out record for the authenticated employee.
179
+
180
+ Args:
181
+ payload: Check-out request with timestamp, latitude, and longitude
182
+ current_user: Authenticated user from JWT token
183
+
184
+ Returns:
185
+ CheckOutResponse with success status and total working minutes
186
+
187
+ Raises:
188
+ HTTPException 400: If no check-in found, already checked out, or GPS disabled
189
+ HTTPException 500: If internal error occurs
190
+ """
191
+ try:
192
+ # Get MongoDB database
193
+ mongo_db = get_database()
194
+
195
+ # Create service instance
196
+ service = get_attendance_service(mongo_db)
197
+
198
+ # Create check-out
199
+ result = await service.create_checkout(
200
+ employee_id=current_user.user_id,
201
+ merchant_id=current_user.merchant_id,
202
+ payload=payload
203
+ )
204
+
205
+ logger.info(
206
+ "Check-out successful",
207
+ extra={
208
+ "employee_id": current_user.user_id,
209
+ "merchant_id": current_user.merchant_id,
210
+ "total_minutes": result.total_minutes
211
+ }
212
+ )
213
+
214
+ return result
215
+
216
+ except ValueError as e:
217
+ # Business logic errors (no check-in, already checked out, GPS disabled)
218
+ error_message = str(e)
219
+ logger.warning(
220
+ f"Check-out validation failed: {error_message}",
221
+ extra={
222
+ "employee_id": current_user.user_id,
223
+ "error": error_message
224
+ }
225
+ )
226
+ raise HTTPException(
227
+ status_code=status.HTTP_400_BAD_REQUEST,
228
+ detail={
229
+ "success": False,
230
+ "error": error_message,
231
+ "detail": error_message
232
+ }
233
+ )
234
+ except Exception as e:
235
+ # Unexpected errors
236
+ logger.error(
237
+ "Check-out failed with unexpected error",
238
+ extra={
239
+ "employee_id": current_user.user_id,
240
+ "error": str(e)
241
+ },
242
+ exc_info=e
243
+ )
244
+ raise HTTPException(
245
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
246
+ detail={
247
+ "success": False,
248
+ "error": "Internal server error",
249
+ "detail": "An unexpected error occurred during check-out"
250
+ }
251
+ )
app/tracker/attendance/schemas.py CHANGED
@@ -71,6 +71,69 @@ class CheckInResponse(BaseModel):
71
  }
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  class ErrorResponse(BaseModel):
75
  """Error response schema"""
76
  success: bool = Field(False, description="Always false for errors")
 
71
  }
72
 
73
 
74
+ class CheckOutRequest(BaseModel):
75
+ """Request schema for check-out"""
76
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
77
+ latitude: float = Field(..., description="GPS latitude", ge=-90, le=90)
78
+ longitude: float = Field(..., description="GPS longitude", ge=-180, le=180)
79
+
80
+ @field_validator('timestamp')
81
+ @classmethod
82
+ def validate_timestamp(cls, v):
83
+ """Validate timestamp is reasonable"""
84
+ if v <= 0:
85
+ raise ValueError("Timestamp must be positive")
86
+ # Check if timestamp is not too far in the past or future (within 24 hours)
87
+ now_ms = int(datetime.now().timestamp() * 1000)
88
+ diff_hours = abs(now_ms - v) / (1000 * 60 * 60)
89
+ if diff_hours > 24:
90
+ raise ValueError("Timestamp is too far from current time")
91
+ return v
92
+
93
+ @field_validator('latitude')
94
+ @classmethod
95
+ def validate_latitude(cls, v):
96
+ """Validate latitude range"""
97
+ if not -90 <= v <= 90:
98
+ raise ValueError("Latitude must be between -90 and 90")
99
+ return v
100
+
101
+ @field_validator('longitude')
102
+ @classmethod
103
+ def validate_longitude(cls, v):
104
+ """Validate longitude range"""
105
+ if not -180 <= v <= 180:
106
+ raise ValueError("Longitude must be between -180 and 180")
107
+ return v
108
+
109
+ model_config = {
110
+ "json_schema_extra": {
111
+ "example": {
112
+ "timestamp": 1708186800000,
113
+ "latitude": 19.0760,
114
+ "longitude": 72.8777
115
+ }
116
+ }
117
+ }
118
+
119
+
120
+ class CheckOutResponse(BaseModel):
121
+ """Response schema for check-out"""
122
+ success: bool = Field(..., description="Whether check-out was successful")
123
+ total_minutes: int = Field(..., description="Total working time in minutes")
124
+ message: Optional[str] = Field(None, description="Success or error message")
125
+
126
+ model_config = {
127
+ "json_schema_extra": {
128
+ "example": {
129
+ "success": True,
130
+ "total_minutes": 480,
131
+ "message": "Check-out successful"
132
+ }
133
+ }
134
+ }
135
+
136
+
137
  class ErrorResponse(BaseModel):
138
  """Error response schema"""
139
  success: bool = Field(False, description="Always false for errors")
app/tracker/attendance/service.py CHANGED
@@ -9,13 +9,16 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
9
 
10
  from app.core.logging import get_logger
11
  from app.postgres import get_postgres_connection, release_postgres_connection
12
- from app.tracker.attendance.schemas import CheckInRequest, CheckInResponse
13
  from app.tracker.attendance.constants import (
14
  EMPLOYEES_COLLECTION,
15
  ERROR_DUPLICATE_CHECKIN,
16
  ERROR_GPS_DISABLED,
17
  ERROR_EMPLOYEE_NOT_FOUND,
18
- SUCCESS_CHECKIN
 
 
 
19
  )
20
 
21
  logger = get_logger(__name__)
@@ -241,6 +244,121 @@ class AttendanceService:
241
  finally:
242
  if conn:
243
  await release_postgres_connection(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
 
246
  def get_attendance_service(mongo_db: AsyncIOMotorDatabase) -> AttendanceService:
 
9
 
10
  from app.core.logging import get_logger
11
  from app.postgres import get_postgres_connection, release_postgres_connection
12
+ from app.tracker.attendance.schemas import CheckInRequest, CheckInResponse, CheckOutRequest, CheckOutResponse
13
  from app.tracker.attendance.constants import (
14
  EMPLOYEES_COLLECTION,
15
  ERROR_DUPLICATE_CHECKIN,
16
  ERROR_GPS_DISABLED,
17
  ERROR_EMPLOYEE_NOT_FOUND,
18
+ ERROR_NO_CHECKIN_FOUND,
19
+ ERROR_ALREADY_CHECKED_OUT,
20
+ SUCCESS_CHECKIN,
21
+ SUCCESS_CHECKOUT
22
  )
23
 
24
  logger = get_logger(__name__)
 
244
  finally:
245
  if conn:
246
  await release_postgres_connection(conn)
247
+
248
+ async def create_checkout(
249
+ self,
250
+ employee_id: str,
251
+ merchant_id: str,
252
+ payload: CheckOutRequest
253
+ ) -> CheckOutResponse:
254
+ """
255
+ Create a check-out record and calculate total working time.
256
+
257
+ Args:
258
+ employee_id: Employee ID
259
+ merchant_id: Merchant ID
260
+ payload: Check-out request data
261
+
262
+ Returns:
263
+ CheckOutResponse with total working minutes
264
+
265
+ Raises:
266
+ ValueError: If validation fails or no check-in found
267
+ """
268
+ # Convert timestamp to date
269
+ work_date = datetime.fromtimestamp(payload.timestamp / 1000).date()
270
+
271
+ # 1. Check if employee exists and has location tracking consent
272
+ has_consent = await self.check_location_tracking_consent(employee_id)
273
+ if not has_consent:
274
+ logger.warning(f"GPS disabled for employee {employee_id}")
275
+ raise ValueError(ERROR_GPS_DISABLED)
276
+
277
+ # 2. Get today's attendance record
278
+ conn = None
279
+ try:
280
+ conn = await get_postgres_connection()
281
+
282
+ # Find today's attendance record
283
+ query = """
284
+ SELECT
285
+ id,
286
+ check_in_time,
287
+ check_out_time
288
+ FROM trans.scm_attendance
289
+ WHERE employee_id = $1::uuid
290
+ AND work_date = $2
291
+ """
292
+
293
+ record = await conn.fetchrow(query, employee_id, work_date)
294
+
295
+ if not record:
296
+ logger.warning(f"No check-in found for employee {employee_id} on {work_date}")
297
+ raise ValueError(ERROR_NO_CHECKIN_FOUND)
298
+
299
+ # Check if already checked out
300
+ if record['check_out_time'] is not None:
301
+ logger.warning(f"Employee {employee_id} already checked out on {work_date}")
302
+ raise ValueError(ERROR_ALREADY_CHECKED_OUT)
303
+
304
+ # Calculate total working time in minutes
305
+ check_in_time_ms = record['check_in_time']
306
+ check_out_time_ms = payload.timestamp
307
+ total_minutes = int((check_out_time_ms - check_in_time_ms) / (1000 * 60))
308
+
309
+ # Ensure total_minutes is not negative
310
+ if total_minutes < 0:
311
+ logger.error(f"Negative working time calculated: {total_minutes} minutes")
312
+ raise ValueError("Check-out time cannot be before check-in time")
313
+
314
+ # Update attendance record with check-out details
315
+ update_query = """
316
+ UPDATE trans.scm_attendance
317
+ SET
318
+ check_out_time = $1,
319
+ check_out_lat = $2,
320
+ check_out_lon = $3,
321
+ total_minutes = $4,
322
+ updated_at = NOW()
323
+ WHERE id = $5
324
+ RETURNING id
325
+ """
326
+
327
+ result = await conn.fetchval(
328
+ update_query,
329
+ payload.timestamp,
330
+ payload.latitude,
331
+ payload.longitude,
332
+ total_minutes,
333
+ record['id']
334
+ )
335
+
336
+ logger.info(
337
+ f"Check-out created successfully",
338
+ extra={
339
+ "attendance_id": str(result),
340
+ "employee_id": employee_id,
341
+ "merchant_id": merchant_id,
342
+ "work_date": str(work_date),
343
+ "total_minutes": total_minutes
344
+ }
345
+ )
346
+
347
+ return CheckOutResponse(
348
+ success=True,
349
+ total_minutes=total_minutes,
350
+ message=SUCCESS_CHECKOUT
351
+ )
352
+
353
+ except ValueError:
354
+ # Re-raise ValueError (business logic errors)
355
+ raise
356
+ except Exception as e:
357
+ logger.error(f"Error creating check-out: {e}", exc_info=e)
358
+ raise
359
+ finally:
360
+ if conn:
361
+ await release_postgres_connection(conn)
362
 
363
 
364
  def get_attendance_service(mongo_db: AsyncIOMotorDatabase) -> AttendanceService: