Add attendance and visit management features with database schema updates
- Introduced new `attendances` and `visits` tables to manage employee attendance and customer visits, including relevant fields for check-in and check-out details. - Updated `company_settings` to include a `check_in_radius_meters` column for attendance validation. - Implemented foreign key constraints to ensure data integrity between `attendances`, `visits`, `employees`, `branches`, and other related entities. - Created new services and controllers for handling attendance and visit operations, including check-in, check-out, and bulk actions. - Enhanced DTOs for attendance and visit data transfer, including validation for input data. - Added unit and integration tests to validate the new functionalities and ensure proper handling of attendance and visit records. - Created migration scripts to apply the necessary database schema changes for the new features.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import {
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { AttendancesRepository } from '../attendances/attendances.repository';
|
||||
import { CompanySettingsService } from '../settings/company-settings.service';
|
||||
import {
|
||||
isCheckInMethod,
|
||||
verifyCustomerCheckIn,
|
||||
type CheckInPayload,
|
||||
} from '../shared/check-in-verification';
|
||||
import { FIELD_VISIT_PRIVILEGE_KEY } from '../shared/field-purpose';
|
||||
import type { Visit } from './visit';
|
||||
import type {
|
||||
ListVisitsQueryDto,
|
||||
VisitCheckInDto,
|
||||
VisitCheckOutDto,
|
||||
VisitDto,
|
||||
} from './dto/visit.dto';
|
||||
import { VisitsRepository } from './visits.repository';
|
||||
|
||||
@Injectable()
|
||||
export class VisitsService {
|
||||
constructor(
|
||||
private readonly visitsRepository: VisitsRepository,
|
||||
private readonly attendancesRepository: AttendancesRepository,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly companySettingsService: CompanySettingsService,
|
||||
) {}
|
||||
|
||||
async list(query: ListVisitsQueryDto): Promise<PaginationResponse<VisitDto>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.visitsRepository.list({
|
||||
employeeId: query.employeeId,
|
||||
customerId: query.customerId,
|
||||
date: query.date,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<VisitDto> {
|
||||
const visit = await this.visitsRepository.findById(id);
|
||||
if (!visit) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
return this.toItem(visit);
|
||||
}
|
||||
|
||||
async findCurrent(userId: string): Promise<VisitDto | null> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const visit = await this.visitsRepository.findOpenByEmployeeId(employee.id);
|
||||
return visit ? this.toItem(visit) : null;
|
||||
}
|
||||
|
||||
async checkIn(dto: VisitCheckInDto, userId: string): Promise<VisitDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const openVisit = await this.visitsRepository.findOpenByEmployeeId(
|
||||
employee.id,
|
||||
);
|
||||
if (openVisit) {
|
||||
throw new ConflictException('A customer visit is already open');
|
||||
}
|
||||
|
||||
const openAttendance =
|
||||
await this.attendancesRepository.findOpenByEmployeeId(employee.id);
|
||||
if (!openAttendance) {
|
||||
throw new BadRequestException(
|
||||
'Branch check-in is required before visiting a customer',
|
||||
);
|
||||
}
|
||||
|
||||
const customer = await this.customersService.findById(dto.customerId);
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyCustomerCheckIn(
|
||||
{
|
||||
code: customer.code,
|
||||
nfcId: customer.nfcId,
|
||||
latitude: customer.latitude,
|
||||
longitude: customer.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
);
|
||||
|
||||
let planDestinationId: string | null = null;
|
||||
if (dto.planId) {
|
||||
planDestinationId = await this.visitsRepository.findPlanDestinationId(
|
||||
dto.planId,
|
||||
dto.customerId,
|
||||
);
|
||||
}
|
||||
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const created = await this.visitsRepository.create({
|
||||
employeeId: employee.id,
|
||||
customerId: dto.customerId,
|
||||
attendanceId: openAttendance.id,
|
||||
planId: dto.planId ?? null,
|
||||
planDestinationId,
|
||||
date: now.startOfDay(),
|
||||
checkInAt: now,
|
||||
checkInMethod: verified.method,
|
||||
checkInLatitude: verified.latitude,
|
||||
checkInLongitude: verified.longitude,
|
||||
checkInPhotoUrl: verified.photoUrl,
|
||||
checkInDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
return this.toItem(created);
|
||||
}
|
||||
|
||||
async checkOut(
|
||||
id: string,
|
||||
dto: VisitCheckOutDto,
|
||||
userId: string,
|
||||
): Promise<VisitDto> {
|
||||
const employee = await this.employeesService.requireByUserId(userId);
|
||||
const visit = await this.visitsRepository.findById(id);
|
||||
if (!visit) {
|
||||
throw new NotFoundException('Visit not found');
|
||||
}
|
||||
if (visit.employeeId !== employee.id) {
|
||||
throw new BadRequestException('Visit does not belong to this user');
|
||||
}
|
||||
if (visit.checkOutAt) {
|
||||
throw new ConflictException('Visit is already checked out');
|
||||
}
|
||||
|
||||
const customer = await this.customersService.findById(visit.customerId);
|
||||
const radiusMeters =
|
||||
await this.companySettingsService.requireCheckInRadiusMeters();
|
||||
const payload = this.toPayload(dto);
|
||||
const verified = verifyCustomerCheckIn(
|
||||
{
|
||||
code: customer.code,
|
||||
nfcId: customer.nfcId,
|
||||
latitude: customer.latitude,
|
||||
longitude: customer.longitude,
|
||||
},
|
||||
payload,
|
||||
radiusMeters,
|
||||
);
|
||||
|
||||
const updated = await this.visitsRepository.checkOut(id, {
|
||||
checkOutAt: DateTime.fromUnixMs(Date.now()),
|
||||
checkOutMethod: verified.method,
|
||||
checkOutLatitude: verified.latitude,
|
||||
checkOutLongitude: verified.longitude,
|
||||
checkOutPhotoUrl: verified.photoUrl,
|
||||
checkOutDistanceMeters: verified.distanceMeters,
|
||||
userId,
|
||||
});
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<VisitDto> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.visitsRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.visitsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.visitsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.visitsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
toItem(visit: Visit): VisitDto {
|
||||
return {
|
||||
id: visit.id,
|
||||
employee: pickRelation(visit.employee, DEFAULT_RELATION_FIELDS)!,
|
||||
customer: pickRelation(visit.customer, DEFAULT_RELATION_FIELDS)!,
|
||||
attendanceId: visit.attendanceId,
|
||||
planId: visit.planId,
|
||||
planDestinationId: visit.planDestinationId,
|
||||
date: visit.date.value,
|
||||
checkInAt: visit.checkInAt.value,
|
||||
checkInMethod: visit.checkInMethod,
|
||||
checkInLatitude: visit.checkInLatitude,
|
||||
checkInLongitude: visit.checkInLongitude,
|
||||
checkInPhotoUrl: visit.checkInPhotoUrl,
|
||||
checkInDistanceMeters: visit.checkInDistanceMeters,
|
||||
checkOutAt: visit.checkOutAt?.value ?? null,
|
||||
checkOutMethod: visit.checkOutMethod,
|
||||
checkOutLatitude: visit.checkOutLatitude,
|
||||
checkOutLongitude: visit.checkOutLongitude,
|
||||
checkOutPhotoUrl: visit.checkOutPhotoUrl,
|
||||
checkOutDistanceMeters: visit.checkOutDistanceMeters,
|
||||
status: visit.status.value,
|
||||
createdAt: visit.createdAt.value,
|
||||
updatedAt: visit.updatedAt.value,
|
||||
createdBy: pickUserRelation(
|
||||
visit.createdByUser ?? { id: visit.createdBy, username: '' },
|
||||
),
|
||||
updatedBy: pickUserRelation(
|
||||
visit.updatedByUser ?? { id: visit.updatedBy, username: '' },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toPayload(dto: VisitCheckInDto | VisitCheckOutDto): CheckInPayload {
|
||||
if (!isCheckInMethod(dto.method)) {
|
||||
throw new BadRequestException('Invalid check-in method');
|
||||
}
|
||||
return {
|
||||
method: dto.method,
|
||||
nfcId: dto.nfcId,
|
||||
qrCode: dto.qrCode,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
photoUrl: dto.photoUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void FIELD_VISIT_PRIVILEGE_KEY;
|
||||
Reference in New Issue
Block a user