import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm'; import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities'; import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto'; export interface BranchSearchParams { search?: string; branchType?: string; isActive?: boolean; parentId?: string; includeChildren?: boolean; limit?: number; offset?: number; } export class BranchesService { constructor( private readonly branchRepository: Repository, private readonly assignmentRepository: Repository, private readonly scheduleRepository: Repository, private readonly terminalRepository: Repository ) {} // ============================================ // BRANCH CRUD // ============================================ async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> { const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params; const where: FindOptionsWhere = { tenantId }; if (branchType) where.branchType = branchType as any; if (isActive !== undefined) where.isActive = isActive; if (parentId) where.parentId = parentId; if (parentId === null) where.parentId = IsNull(); const queryBuilder = this.branchRepository .createQueryBuilder('branch') .where('branch.tenant_id = :tenantId', { tenantId }) .leftJoinAndSelect('branch.schedules', 'schedules') .leftJoinAndSelect('branch.paymentTerminals', 'terminals'); if (search) { queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', { search: `%${search}%`, }); } if (branchType) { queryBuilder.andWhere('branch.branch_type = :branchType', { branchType }); } if (isActive !== undefined) { queryBuilder.andWhere('branch.is_active = :isActive', { isActive }); } if (parentId) { queryBuilder.andWhere('branch.parent_id = :parentId', { parentId }); } else if (parentId === null) { queryBuilder.andWhere('branch.parent_id IS NULL'); } queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC'); const total = await queryBuilder.getCount(); const data = await queryBuilder.skip(offset).take(limit).getMany(); return { data, total }; } async findOne(id: string): Promise { return this.branchRepository.findOne({ where: { id }, relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'], }); } async findByCode(tenantId: string, code: string): Promise { return this.branchRepository.findOne({ where: { tenantId, code }, relations: ['schedules', 'paymentTerminals'], }); } async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise { // Check for duplicate code const existing = await this.findByCode(tenantId, dto.code); if (existing) { throw new Error(`Branch with code '${dto.code}' already exists`); } // Build hierarchy path let hierarchyPath = `/${dto.code}`; let hierarchyLevel = 0; if (dto.parentId) { const parent = await this.findOne(dto.parentId); if (!parent) { throw new Error('Parent branch not found'); } hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; hierarchyLevel = parent.hierarchyLevel + 1; } const branch = this.branchRepository.create({ ...dto, tenantId, hierarchyPath, hierarchyLevel, createdBy, }); return this.branchRepository.save(branch); } async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise { const branch = await this.findOne(id); if (!branch) return null; // If changing parent, update hierarchy if (dto.parentId !== undefined && dto.parentId !== branch.parentId) { if (dto.parentId) { const newParent = await this.findOne(dto.parentId); if (!newParent) { throw new Error('New parent branch not found'); } // Check for circular reference if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) { throw new Error('Cannot create circular reference in branch hierarchy'); } branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`; branch.hierarchyLevel = newParent.hierarchyLevel + 1; } else { branch.hierarchyPath = `/${branch.code}`; branch.hierarchyLevel = 0; } // Update children hierarchy paths await this.updateChildrenHierarchy(branch); } Object.assign(branch, dto, { updatedBy }); return this.branchRepository.save(branch); } private async updateChildrenHierarchy(parent: Branch): Promise { const children = await this.branchRepository.find({ where: { parentId: parent.id }, }); for (const child of children) { child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`; child.hierarchyLevel = parent.hierarchyLevel + 1; await this.branchRepository.save(child); await this.updateChildrenHierarchy(child); } } async delete(id: string): Promise { const branch = await this.findOne(id); if (!branch) return false; // Check if has children const childrenCount = await this.branchRepository.count({ where: { parentId: id } }); if (childrenCount > 0) { throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.'); } await this.branchRepository.softDelete(id); return true; } // ============================================ // HIERARCHY // ============================================ async getHierarchy(tenantId: string): Promise { const branches = await this.branchRepository.find({ where: { tenantId, isActive: true }, order: { hierarchyPath: 'ASC' }, }); return this.buildTree(branches); } private buildTree(branches: Branch[], parentId: string | null = null): Branch[] { return branches .filter((b) => b.parentId === parentId) .map((branch) => ({ ...branch, children: this.buildTree(branches, branch.id), })); } async getChildren(branchId: string, recursive: boolean = false): Promise { if (!recursive) { return this.branchRepository.find({ where: { parentId: branchId, isActive: true }, order: { name: 'ASC' }, }); } const parent = await this.findOne(branchId); if (!parent) return []; return this.branchRepository .createQueryBuilder('branch') .where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` }) .andWhere('branch.is_active = true') .orderBy('branch.hierarchy_path', 'ASC') .getMany(); } async getParents(branchId: string): Promise { const branch = await this.findOne(branchId); if (!branch || !branch.hierarchyPath) return []; const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code); if (codes.length === 0) return []; return this.branchRepository.find({ where: { tenantId: branch.tenantId, code: In(codes) }, order: { hierarchyLevel: 'ASC' }, }); } // ============================================ // USER ASSIGNMENTS // ============================================ async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise { // Check if branch exists const branch = await this.findOne(dto.branchId); if (!branch || branch.tenantId !== tenantId) { throw new Error('Branch not found'); } // Check for existing assignment of same type const existing = await this.assignmentRepository.findOne({ where: { userId: dto.userId, branchId: dto.branchId, assignmentType: (dto.assignmentType as any) ?? 'primary', }, }); if (existing) { // Update existing Object.assign(existing, { branchRole: dto.branchRole ?? existing.branchRole, permissions: dto.permissions ?? existing.permissions, validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil, isActive: true, }); return this.assignmentRepository.save(existing); } const assignment = this.assignmentRepository.create({ ...dto, tenantId, validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined, createdBy: assignedBy, } as any); return this.assignmentRepository.save(assignment); } async unassignUser(userId: string, branchId: string): Promise { const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false }); return (result.affected ?? 0) > 0; } async getUserBranches(userId: string): Promise { const assignments = await this.assignmentRepository.find({ where: { userId, isActive: true }, relations: ['branch'], }); return assignments.map((a) => a.branch).filter((b) => b != null); } async getBranchUsers(branchId: string): Promise { return this.assignmentRepository.find({ where: { branchId, isActive: true }, order: { branchRole: 'ASC' }, }); } async getPrimaryBranch(userId: string): Promise { const assignment = await this.assignmentRepository.findOne({ where: { userId, assignmentType: 'primary' as any, isActive: true }, relations: ['branch'], }); return assignment?.branch ?? null; } // ============================================ // GEOFENCING // ============================================ async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> { const branch = await this.findOne(branchId); if (!branch) { throw new Error('Branch not found'); } if (!branch.geofenceEnabled) { return { valid: true, distance: 0 }; } if (!branch.latitude || !branch.longitude) { return { valid: true, distance: 0 }; } // Calculate distance using Haversine formula const R = 6371000; // Earth's radius in meters const dLat = this.toRad(latitude - branch.latitude); const dLon = this.toRad(longitude - branch.longitude); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const distance = R * c; return { valid: distance <= branch.geofenceRadius, distance: Math.round(distance), }; } private toRad(deg: number): number { return deg * (Math.PI / 180); } async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise { // Use PostgreSQL's earthdistance extension if available, otherwise calculate in app const branches = await this.branchRepository.find({ where: { tenantId, isActive: true }, }); return branches .filter((b) => { if (!b.latitude || !b.longitude) return false; const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude); return result <= radiusMeters; }) .sort((a, b) => { const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!); const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!); return distA - distB; }); } private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; const dLat = this.toRad(lat2 - lat1); const dLon = this.toRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } // ============================================ // SCHEDULES // ============================================ async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise { const schedule = this.scheduleRepository.create({ ...dto, branchId, specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined, }); return this.scheduleRepository.save(schedule); } async getSchedules(branchId: string): Promise { return this.scheduleRepository.find({ where: { branchId, isActive: true }, order: { dayOfWeek: 'ASC', specificDate: 'ASC' }, }); } async isOpenNow(branchId: string): Promise { const schedules = await this.getSchedules(branchId); const now = new Date(); const dayOfWeek = now.getDay(); const currentTime = now.toTimeString().slice(0, 5); // Check for specific date schedule first const today = now.toISOString().slice(0, 10); const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today); if (specificSchedule) { return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime; } // Check regular schedule const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular'); if (regularSchedule) { return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime; } return false; } // ============================================ // MAIN BRANCH // ============================================ async getMainBranch(tenantId: string): Promise { return this.branchRepository.findOne({ where: { tenantId, isMain: true, isActive: true }, relations: ['schedules', 'paymentTerminals'], }); } async setAsMainBranch(branchId: string): Promise { const branch = await this.findOne(branchId); if (!branch) return null; // Unset current main branch await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false }); // Set new main branch branch.isMain = true; return this.branchRepository.save(branch); } }